Skip to content

REST API

Bearer-token authenticated JSON API. All endpoints are version-flat: the same server build serves both hosted (syncbins.com) and self-hosted boxes. Payloads are validated against the zod schemas in shared/src/api.ts — that file is the source of truth.

  • Base URL: https://<your-box>/api. Hosted is https://syncbins.com/api.
  • Auth: Authorization: Bearer <session_token>. Tokens are issued at pair time and rotated on revocation.
  • IDs: ULIDs (Crockford Base32, 26 chars). Lexicographically sortable by creation time.
  • Timestamps: milliseconds since epoch, integer.
  • Binary fields: base64 (standard, no URL-safe).
  • Errors: JSON envelope under error (see below). HTTP status reflects the failure class.
Terminal window
curl https://box.yourdomain.com/api/items?since=4218 \
-H "Authorization: Bearer ${SB_TOKEN}" \
-H "Accept: application/json"

The core of the API. Items are append-mostly — you create them, fetch them since a version cursor, and soft-delete them.

PUT /items auth: device

Create or replace an item. Idempotent on the client-supplied id.

  • id string required — Client-generated ULID.
  • bin_id string required — Target bin.
  • type ContentType required — One of the 10 content types.
  • ts integer required — Client timestamp (ms).
  • nonce base64(24) required — XChaCha20 nonce.
  • ct base64 required — Ciphertext. Max 64 KB inline; use blob_ref for larger.
  • wrapped WrappedKey[] required — K_item wrapped per active device pubkey.
  • blob_ref string | null — Storage backend reference when ct lives in blob storage.
Request body
{
"id": "01JC4M9KJ7XAHQ3RT8VFNPE2QH",
"bin_id": "01JC4ABCDEFGH",
"type": "password",
"ts": 1747843261000,
"nonce": "base64(24 bytes)",
"ct": "base64(ciphertext)",
"wrapped": [
{ "device_id": "", "key": "base64(48 bytes)" }
],
"blob_ref": null
}
200 OK · response
{
"id": "01JC4M9KJ7XAHQ3RT8VFNPE2QH",
"version": 4218,
"server_ts": 1747843261103
}
GET /items auth: device

Page items since a version cursor. Used for catch-up after reconnect.

  • since integer required — Last version the client saw.
  • limit integer — Default 200, max 1000.
  • bin_id string — Filter to a single bin.
200 OK · response
{
"items": [
{ "id": "", "version": 4218, "bin_id": "",
"type": "link", "ct": "", "wrapped": [],
"ts": 1747843001000 }
],
"head_version": 4218,
"has_more": false
}
DEL /items/:id auth: device

Soft delete. Hard delete runs nightly after 30 days.

  • id string required — ULID of the item.

For items larger than 64 KB. Two-step flow: request a short-lived SAS URL, upload the ciphertext directly to your storage backend, then create the item with a blob_ref pointing at it.

POST /blobs/upload-url auth: device

Get a pre-signed upload URL (Azure SAS / S3 / R2). Expires in 15 min.

  • size integer required — Ciphertext size in bytes. Server enforces tenant cap.
  • content_type string — Defaults to application/octet-stream.
GET /blobs/:ref/url auth: device

Short-lived download URL (5 min). Refresh on 403.

Multi-step handshake described in How encryption works → Pairing. Each step is its own endpoint so the existing device can stay in control of when to release the wrapped master key.

POST /devices/pair-init auth: device

Existing device starts a pair session, returns the 6-word code lookup id.

GET /devices/pair-fetch-epk auth: none (rate-limited)

New device redeems the 6-word code for the existing device's ephemeral pubkey.

  • code string required — BIP39 6-word string entered by the user.
POST /devices/pair-fetch-npk auth: none

New device submits its pubkey + attestation. Server forwards to existing device over WS.

POST /devices/pair-wrap auth: device

Existing device wraps the master key to the new device's pubkey.

GET /devices auth: device

List paired devices. Used by Settings → Devices.

POST /devices/:id/revoke auth: device

Revoke a device. Token is invalidated immediately; bundle of wrapped keys is purged on next item put.

A brand-new server has no devices and no auth. The first device claims it through a one-shot bootstrap. After this returns, every subsequent device must go through pairing.

POST /devices/bootstrap auth: none · one-shot

Claim an empty server. 410 Gone once a device exists.

POST /devices/bootstrap/restart auth: admin · self-host only

Re-arm bootstrap. Wipes all device records. Items are kept but unrecoverable.

POST /password/set auth: device

Set or change the account password (second factor).

POST /password/verify auth: none

Verify before unsealing the master key on a new device.

GET /password/exists auth: none

Probe — is a password configured? Used by the unlock screen.

Only mounted when TENANT_MODE=multi. Self-hosted boxes can ignore these.

POST /signup auth: none

Allocate a new tenant + subdomain.

GET /tls-check auth: none

Has Caddy provisioned a certificate for this subdomain yet?

POST /billing/webhook auth: stripe

Stripe webhook receiver (subscription events).

GET /billing/portal auth: device

Issues a Stripe customer portal session URL.

GET /me auth: device

Current device info, server version, tenant stats.

GET /syncbin/status auth: none

Liveness + bootstrap state. Used by deploy scripts.

Every non-2xx response carries a JSON body with a stable error.code. See Error codes for the full catalogue and recovery guidance.

400 Bad Request · response
{
"error": {
"code": "item_too_large",
"message": "Inline payload limit is 64 KB. Use POST /blobs/upload-url for larger items.",
"hint": "See /reference/errors#item_too_large"
}
}
StatusClassCommon codes
400Bad requestinvalid_payload, item_too_large
401Unauthenticatedmissing_token, expired_token
403Forbiddendevice_revoked, tenant_suspended
404Not founditem_not_found, bin_not_found
409Conflictbootstrap_already_done, device_exists
413Too largeblob_too_large
429Rate limitedrate_limited — backoff in Retry-After
500Serverinternal — open a ticket