Server API Contracts
The default RestSyncTransport speaks plain HTTP + JSON. This page lists every request and response shape so you can wire up a backend in any language. If your server can’t (or shouldn’t) speak this protocol, swap the transport — see Custom Transports.
Endpoint summary
Section titled “Endpoint summary”| Stream | Method | Path | Request body | Response body |
|---|---|---|---|---|
| Outbox — Create | POST | {Url} | T (the entity) | server-defined (passed to OnSent) |
| Outbox — Update | PUT | {Url}/{id} | T | server-defined |
| Outbox — Delete | DELETE | {Url}/{id} | — | — |
| Outbox — Batch | POST | {BatchUrl} or {Url}/batch | SyncBatchRequest | SyncBatchResponse |
| Inbox — Pull | GET | {PullUrl} or {Url} | — | SyncPullResponse |
| Inbox — Tombstones | GET | {TombstoneUrl} | — | string[] or { cursor, ids } |
Default headers on every request:
Content-Type: application/jsonon writesAccept: application/jsonon reads- Anything added by
ISyncInterceptorandSyncEndpoint.OnBeforeSend
Inbox pull
Section titled “Inbox pull”The default cursor parameter is since (override with CursorParameter or omit it with null):
GET /todos?since=2026-06-01T00:00:00ZExpected response (SyncPullResponse):
{ "cursor": "<opaque next cursor>", "hasMore": false, "items": [ { "id": "abc", "verb": "Create", "payload": { "identifier": "abc", "title": "Buy milk", "completed": false } }, { "id": "def", "verb": "Update", "payload": { "identifier": "def", "title": "Walk dog", "completed": true } }, { "id": "ghi", "verb": "Delete" } ]}Notes:
verbis one of"Create","Update","Delete"(case-insensitive).payloadis required forCreate/Update. ForDeleteit can be omitted,null, or{}.- When
hasMore: true, the engine immediately re-pulls with the new cursor — onePullNow/PullAllcall drains the full delta set. HTTP 304 Not Modifiedis treated as “no changes” —LastPulledAtis bumped but the cursor is left alone.
Cursors
Section titled “Cursors”The cursor is opaque — Shiny.Data.Sync persists whatever string you return and round-trips it on the next pull. Common patterns:
- ISO-8601 timestamp of the last change you returned
- Monotonic integer (database
xmin, transaction id, snapshot lsn, etc.) - Combined “timestamp + entity id” tuple for tie-breaking
Outbox — single-send (Batch = false)
Section titled “Outbox — single-send (Batch = false)”The engine emits one HTTP request per queued op:
POST /todosContent-Type: application/json
{ "identifier": "abc", "title": "Buy milk", "completed": false }PUT /todos/abcContent-Type: application/json
{ "identifier": "abc", "title": "Buy milk", "completed": true }DELETE /todos/abcAny 2xx is treated as success. The full response body is passed to IDataSyncDelegate.OnSent(op, responseBody) so you can read a server-assigned id, ETag, or whatever you need.
Outbox — batched (Batch = true)
Section titled “Outbox — batched (Batch = true)”When Batch = true, all queued ops for the endpoint are coalesced into a single request:
POST /todos/batchContent-Type: application/json
{ "operations": [ { "id": "<op-guid>", "entityId": "abc", "verb": "Create", "payload": { ... } }, { "id": "<op-guid>", "entityId": "def", "verb": "Update", "payload": { ... } }, { "id": "<op-guid>", "entityId": "ghi", "verb": "Delete" } ]}Coalescing happens client-side before the request — trailing Delete wins, Create + Update(s) collapses into a single Create with the latest payload, and Update + Update(s) collapses into a single Update.
Expected response (SyncBatchResponse):
{ "results": [ { "id": "<op-guid>", "status": 200, "body": { /* server's serialized entity */ }, "error": null }, { "id": "<op-guid>", "status": 409, "body": { /* current remote state */ }, "error": null }, { "id": "<op-guid>", "status": 500, "body": null, "error": "Unexpected database error" } ]}Notes:
- One result per op, keyed by the
idthe client sent. statusis the per-op HTTP status. The conflict / retry / error logic runs per result, exactly as if each op had been sent individually.bodyfor a successful op is passed toOnSent. For 409 / 412 it’s the remote payload passed toOnConflict.erroris a free-form server-side message attached toSyncOperation.LastError.
The Apple NSURLSession path does not batch — it sends one upload task per op by design. Everything else (Android, Windows, Linux, macOS, Blazor) honours the flag.
Tombstones
Section titled “Tombstones”GET /todos/deleted?since=2026-06-01T00:00:00ZEither of two shapes is accepted:
["abc", "def", "ghi"]{ "cursor": "<opaque next cursor>", "ids": ["abc", "def", "ghi"] }The second shape lets the tombstone stream advance its cursor independently from the main pull — useful when deletes pile up and need their own pagination.
Retry status codes
Section titled “Retry status codes”The engine treats these as transient and schedules a retry with exponential backoff:
0— network down / connection error408— request timeout429— rate limited (respectsRetry-Afterif you add it)5xx— server error
Everything else (4xx besides the above, or a 2xx with a parse error) is terminal:
409/412— handed toOnConflictper the endpoint’sDefaultConflictPolicy- everything else — handed to
OnErrorafter the retry budget is exhausted
CORS for Blazor
Section titled “CORS for Blazor”Shiny.Data.Sync.Blazor runs in the browser, so your server must respond to preflight OPTIONS requests and include Access-Control-Allow-Origin. ASP.NET example:
app.UseCors(p => p .WithOrigins("https://myapp.example.com") .AllowAnyMethod() .AllowAnyHeader());A reference implementation
Section titled “A reference implementation”A working ASP.NET reference server (using Shiny.DocumentDb.Sqlite for storage) lives in this repo at samples/Sample.Api. It covers every endpoint above plus a /{type}/current convenience route for inspecting state during development.