SyncForge defines a minimal delta-sync HTTP contract. The reference implementations are:
- Client:
KtorSyncTransport(:syncforgeandroidMain) - Server (production starter):
:backend-starter(Ktor) or:backend-starter-spring(Spring Boot) +:syncforge-serverlibrary - Server (local dev / demos):
:mock-server(adds/dev/*routes for conflict demos) - GraphQL wire format: same push/pull semantics via
syncPush/syncPull— see syncforge-server/graphql/README.md and RECIPES.md
DTOs live in dev.syncforge.network.api (commonMain) and are shared between client
and server.
SyncForge separates sync semantics (what SyncManager needs) from wire format (how
bytes reach your backend). The stable client boundary is SyncTransport —
push() and pull() returning PushResult / PullResult. Wire format is pluggable.
Regardless of REST, GraphQL, BaaS RPC, or a custom SDK, adapters must preserve the meaning of the operations documented below:
| Operation | Client input | Client output | Server/store obligation |
|---|---|---|---|
| Push | List<OutboxEntry> with changeType, payloadJson, localVersion |
acknowledgedIds + rejected[] with code / message |
Idempotent batch apply; per-entry rejection without failing the whole batch |
| Pull | sinceTimestampMillis, entityTypes, pageSize, pageCursor |
deltas[], serverTimestampMillis, optional hasMore / nextPageCursor |
Deltas after cursor; tombstones via isDeleted; monotonic serverVersion |
payloadJson is opaque at the SyncForge layer — entity handlers parse it on the client;
backends must store and return it unchanged.
Push rejection code values and pull pagination fields in this document are the canonical
names and semantics. GraphQL field names (syncPush, syncPull) and Supabase RPC names
may differ on the wire but must map to the same shapes. See
syncforge-server/graphql/syncforge-sync.graphql.
| Wire format | Adapter | Module |
|---|---|---|
REST POST /sync/push, GET /sync/pull |
KtorSyncTransport (default) |
:syncforge-network-ktor |
GraphQL syncPush / syncPull |
GraphQlSyncTransport |
:syncforge-transport-graphql |
| BaaS delta store | DeltaStoreSyncTransport + vendor SyncDeltaStore |
:syncforge-transport-core + :syncforge-transport-supabase / :syncforge-transport-firebase |
Implement SyncTransport directly, or SyncDeltaStore + DeltaStoreSyncTransport for hosted
stores. Full guide: CUSTOM_TRANSPORT.md.
Non-goals: SyncForge does not require a specific URL layout, GraphQL schema for your
domain entities, or HTTP status handling beyond what your adapter maps to SyncError.Code.
Built-in auth { } routes (/auth/*) are separate from sync transport endpoints.
The HTTP contract is versioned with the SyncForge library using Semantic Versioning:
| Library version | Contract status |
|---|---|
0.x.y |
Evolving — endpoints and JSON fields may change in minor releases; breaking changes are documented in CHANGELOG.md |
1.0.0+ |
Stable — breaking HTTP/JSON changes only in major library releases |
There is no separate URL prefix (e.g. /v1/sync/push) in 1.0. Paths stay /sync/push and
/sync/pull. Backends implement the contract that matches the SyncForge client version they
target.
| In scope | Notes |
|---|---|
POST /sync/push |
Request/response JSON shape in this document |
GET /sync/pull |
Query params and response JSON shape in this document |
PushRejection.code values |
NETWORK, AUTH, CONFLICT, VALIDATION, SERVER (+ unknown → client UNKNOWN) |
| Pagination fields | hasMore, nextPageCursor, limit, cursor — optional on server; clients handle absence |
Authorization: Bearer |
Optional; 401 / 403 semantics as documented |
| Item | Notes |
|---|---|
GET /health |
Mock/dev convenience only |
POST /dev/simulate-edit |
Mock/dev only — never implement in production |
Entity payloadJson schemas |
Your domain — version per entityType in your API, not SyncForge semver |
| Undocumented endpoints or fields | Clients ignore unknown JSON fields today; do not rely on that for required server behaviour |
Patch (0.x patch / 1.x patch) — no contract migration required:
- Documentation clarifications
- Mock-server bug fixes with no wire-format change
- Server-side performance or idempotency improvements
Minor (0.x minor / 1.x minor) — backward compatible for conforming servers and SyncForge clients:
- New optional JSON fields on requests or responses (clients and servers must ignore unknown fields)
- New optional query parameters on
GET /sync/pull - New rejection
codevalues (map to clientSyncError.Code.UNKNOWNuntil library support is added)
Major (1.x → 2.x) — breaking contract change; requires coordinated upgrade:
- Removing or renaming endpoints
- Changing required JSON fields or types
- Changing meaning of existing fields (e.g. cursor semantics)
- Making optional pagination fields required without a compatibility period
Breaking changes are listed under Removed or Changed in CHANGELOG.md and require a
major SyncForge release. Migration notes for backend authors are published in the same release.
- Implement
POST /sync/pushandGET /sync/pullas specified below. - Return
200with the documented JSON bodies on success; use4xx/5xxwith a body only when your framework requires it — SyncForge maps transport failures toSyncError, not response bodies. - Treat push batches as idempotent where possible (safe client retries).
- Use monotonic
serverVersionandupdatedAtMillisper entity; honour tombstones on pull (isDeleted: true). - Pin your tested contract to a SyncForge release (e.g.
2.0.0) in your service README or OpenAPI description.
| Target | How |
|---|---|
| Production starter (Ktor) | ./gradlew :backend-starter:run — see backend-starter/README.md |
| Production starter (Spring) | ./gradlew :backend-starter-spring:bootRun — JDBC via jdbc profile; see backend-starter-spring/README.md |
| Local dev + conflict demos | ./gradlew :mock-server:run — contract + /dev/simulate-edit |
| GraphQL reference | ./gradlew :backend-starter-graphql:run — POST /graphql only |
| CI / samples | :mock-server on port 8080; Android emulator uses http://10.0.2.2:8080 |
| Contract drift | Run client E2E (./gradlew androidE2e, ./gradlew iosE2e) against your backend before upgrading SyncForge major versions |
If a /v2 path or header-based negotiation is introduced later, it will be announced in
CHANGELOG.md with a deprecation window for the 1.x wire format. 1.0 clients do not send
a contract version header.
All paths are relative to the configured base URL, e.g. https://api.example.com or
http://10.0.2.2:8080 (Android emulator → host machine).
Clients may send:
Authorization: Bearer <token>
KtorSyncTransport supports this via authTokenProvider: () -> String? or
RefreshingSyncAuthProvider for automatic refresh on expired tokens. The mock server
ignores auth. Production backends should:
| Status | Meaning | Client behavior |
|---|---|---|
401 |
Missing/invalid/expired token | SyncError.Code.AUTH; with RefreshingSyncAuthProvider, refresh once and retry |
403 |
Valid auth but forbidden | SyncError.Code.AUTH; no automatic retry |
Push a batch of outbox entries to the server. Entries should be processed idempotently where possible — clients may retry the same batch after network failures.
{
"entries": [
{
"id": 42,
"entityType": "tasks",
"entityId": "550e8400-e29b-41d4-a716-446655440000",
"changeType": "CREATE",
"payloadJson": "{\"id\":\"550e8400-...\",\"title\":\"Buy milk\",\"completed\":false}",
"localVersion": 1,
"createdAtMillis": 1719667200000
}
]
}| Field | Type | Description |
|---|---|---|
id |
Long |
Client-side outbox row ID (used for acknowledgement) |
entityType |
String |
Entity type key, e.g. "tasks" |
entityId |
String |
Primary key of the affected entity |
changeType |
"CREATE" | "UPDATE" | "DELETE" |
Mutation kind |
payloadJson |
String? |
JSON entity payload; null for DELETE |
localVersion |
Long |
Client-side version counter |
createdAtMillis |
Long |
When the outbox entry was created |
{
"acknowledgedIds": [42],
"rejected": []
}| Field | Type | Description |
|---|---|---|
acknowledgedIds |
Long[] |
Outbox IDs the server accepted |
rejected |
PushRejection[] |
Entries the server refused |
Rejection entry:
{
"outboxId": 42,
"code": "VALIDATION",
"message": "title must not be empty"
}code value |
Maps to SyncError.Code |
|---|---|
NETWORK |
NETWORK |
AUTH |
AUTH |
CONFLICT |
CONFLICT |
VALIDATION |
VALIDATION |
SERVER |
SERVER |
| anything else | UNKNOWN |
- CREATE / UPDATE — upsert the entity; assign a monotonic
serverVersionandupdatedAtMillis - DELETE — store a tombstone (
isDeleted: true) so pull clients can propagate the delete - Rejected entries are rolled back locally by SyncForge; acknowledged entries are removed from the outbox
Fetch server-side changes since a cursor timestamp.
| Parameter | Required | Description |
|---|---|---|
since |
No | Epoch millis cursor; 0 means full sync. Default: 0 |
types |
No | Comma-separated entity types, e.g. tasks,notes. Empty = no filter |
limit |
No | Max deltas per response. Maps to SyncConfig.pullPageSize on the client. Omit for a single unpaginated response |
cursor |
No | Opaque page cursor from a previous response's nextPageCursor |
Example:
GET /sync/pull?since=1719667200000&types=tasks&limit=100
GET /sync/pull?since=1719667200000&types=tasks&limit=100&cursor=abc123
{
"deltas": [
{
"entityType": "tasks",
"entityId": "550e8400-e29b-41d4-a716-446655440000",
"payloadJson": "{\"id\":\"550e8400-...\",\"title\":\"Buy milk\",\"completed\":false}",
"serverVersion": 3,
"updatedAtMillis": 1719667300000,
"isDeleted": false
}
],
"serverTimestampMillis": 1719667400000,
"hasMore": false,
"nextPageCursor": null
}| Field | Type | Description |
|---|---|---|
deltas |
RemoteDelta[] |
Changes since since for the requested types |
serverTimestampMillis |
Long |
New cursor — client stores this as the next since value |
hasMore |
Boolean |
true when more pages remain; client loops until false |
nextPageCursor |
String? |
Opaque cursor for the next page; pass as cursor query param |
Delta entry:
| Field | Type | Description |
|---|---|---|
entityType |
String |
Entity type key |
entityId |
String |
Entity primary key |
payloadJson |
String? |
JSON payload; null for tombstones |
serverVersion |
Long |
Server-assigned version |
updatedAtMillis |
Long |
Server timestamp of this change |
isDeleted |
Boolean |
true = tombstone; delete locally |
- SyncForge applies each delta via the matching
EntitySyncHandler - Conflicts are resolved using the configured
ConflictPolicy(default: last-write-wins auto-resolve) - Deferred conflicts (
deferToUser()) are persisted in SyncForge's conflict store and surfaced viaSyncManager.conflicts - When
hasMoreistrue, the client requests subsequent pages usingnextPageCursorbefore advancing the pull cursor - On success,
serverTimestampMillisis persisted viaSyncCursorStore
Optional health check used by the mock server:
{ "status": "ok" }Not required for production backends.
Dev-only endpoint for conflict demos in the sample app. Simulates a concurrent server edit so the next pull produces a conflict against local changes.
The entity must already exist on the server (push first).
{
"entityType": "tasks",
"entityId": "550e8400-e29b-41d4-a716-446655440000",
"payloadJson": "{\"id\":\"550e8400-...\",\"title\":\"Edited on server\",\"completed\":true}"
}| Field | Type | Description |
|---|---|---|
entityType |
String |
Entity type key |
entityId |
String |
Primary key of the entity to update |
payloadJson |
String |
Full replacement JSON payload |
{ "updated": true }Returned when the entity does not exist on the server yet:
{
"updated": false,
"message": "Entity not found — sync the task to the server first"
}Not required for production backends.
Servers that omit limit / cursor remain compatible — a single response with
hasMore: false is treated as one page. Clients using SyncConfig.pullPageSize loop
until all pages are consumed before advancing the pull cursor.
| DTO | Package |
|---|---|
PushRequest |
dev.syncforge.network.api |
OutboxEntryDto |
dev.syncforge.network.api |
PushResponse |
dev.syncforge.network.api |
PushRejectionDto |
dev.syncforge.network.api |
PullResponse |
dev.syncforge.network.api |
RemoteDeltaDto |
dev.syncforge.network.api |
Mapping helpers: OutboxEntry.toDto(), PushResponse.toPushResult(), PullResponse.toPullResult().