1. Handwrite Regular Accounts
Handwrite
  • Handwrite Regular Accounts
    • Start here — Handwrite for outside developers
    • API specification notes
    • Socket specification notes
    • Account
      • README — GET /api/v1/me
      • README — DELETE /api/v1/me
      • README — GET /api/v1/me/policy
      • README — GET /api/v1/me/usage
      • Account API — README
      • Who this credential acts for
      • Delete the account (after a 30-day grace period)
      • What your teams' and classes' policies allow
      • Your own usage, and what your teams and classes counted
    • Documents
      • README — GET /api/v1/documents
      • README — POST /api/v1/documents
      • README — GET /api/v1/documents/search
      • README — GET /api/v1/documents/{id}
      • README — PATCH /api/v1/documents/{id}
      • README — DELETE /api/v1/documents/{id}
      • README — GET /api/v1/documents/{id}/content
      • README — PUT /api/v1/documents/{id}/content
      • README — POST /api/v1/documents/{id}/live
      • README — GET /api/v1/export
      • Documents API — README
      • List your documents (or shared, or changes since)
      • Create a document
      • Search your own and shared documents
      • A document's metadata
      • Change title, text, content type or language
      • Delete a document (owner)
      • The document's text
      • Replace the text
      • Join the document's live co-editing room
      • Every document you own, with its text
    • Files
      • README — GET /api/v1/documents/{id}/file
      • README — POST /api/v1/documents/{id}/file
      • README — POST /api/v1/documents/{id}/file/commit
      • README — POST /api/v1/documents/{id}/extract
      • Files API — README
      • Download the original file
      • Start uploading the original file
      • Make an uploaded file the document's file
      • Replace the text with the file's text
    • Audio
      • README — GET /api/v1/documents/{id}/audio
      • README — POST /api/v1/audio/stream
      • README — POST /api/v1/audio/speech
      • README — GET /api/v1/voices
      • README — GET /api/v1/quota
      • Audio API — README
      • The whole document as one audio file
      • Stream speech as it's made
      • Speech with word timings (read-along)
      • Voices you can use
      • Characters left, limit, plan and reset time
    • Sharing
      • README — GET /api/v1/documents/{id}/permissions
      • README — POST /api/v1/documents/{id}/permissions
      • README — PATCH /api/v1/documents/{id}/permissions/{principal}
      • README — DELETE /api/v1/documents/{id}/permissions/{principal}
      • README — DELETE /api/v1/documents/{id}/invitations/{inviteId}
      • README — GET /api/v1/invitations
      • README — POST /api/v1/invitations/{id}/accept
      • README — POST /api/v1/invitations/{id}/decline
      • Sharing API — README
      • Who a document is shared with
      • Share by principal, or invite by email (owner)
      • Change someone's role (owner)
      • Remove access (owner, or leave yourself)
      • Cancel a pending invitation (owner)
      • Document invitations to your verified email
      • Accept a document invitation
      • Decline a document invitation
    • Settings
      • README — GET /api/v1/settings
      • README — PATCH /api/v1/settings
      • Settings API — README
      • Synced app and reader settings
      • Change settings (per key, the newer updatedAt wins)
    • Webhooks
      • Incoming webhook delivery — README
      • README — GET /api/v1/webhooks
      • README — POST /api/v1/webhooks
      • README — GET /api/v1/webhooks/{id}
      • README — DELETE /api/v1/webhooks/{id}
      • Webhooks API — README
      • Your webhook endpoints
      • Register an endpoint (up to 10)
      • One endpoint
      • Delete an endpoint (queued retries are dropped)
    • Meta
      • README — GET /api/v1/openapi.json
      • Meta API — README
      • This OpenAPI description
    • OAuth
      • README — POST /api/oauth/token
      • README — POST /api/oauth/revoke
      • OAuth API — README
      • Exchange a code, or refresh
      • Revoke a token (RFC 7009)
    • Live socket
      • Live document socket — README
    • A document or sharing event
  1. Handwrite Regular Accounts

Socket specification notes

Eligibility and admission#

Handwrite uses a binary WebSocket protocol for live document co-editing. This is not Socket.IO, a speech socket, or an account-wide event feed. Personal-account integrations can join only when the document/caller qualifies through a team or class with live editing and effective policy permits it. Ordinary standalone personal accounts do not automatically qualify.
Call POST https://handwrites.io/api/v1/documents/{id}/live with a user-owned credential carrying documents:read and access to the document. The server checks access, paid-plan eligibility and liveEditing policy. The principal must be a person; organisation and isolated app test credentials cannot join.
{
  "url": "https://live.handwrites.io/doc/doc_Ab3dEf6hIj9kLm0n",
  "token": "<short-lived room token>",
  "expiresAt": "2026-10-26T13:10:00.000Z",
  "role": "editor",
  "colour": "#2563eb",
  "name": "Reader"
}
The response example is illustrative. Use the returned URL and expiry. Convert its https: scheme to wss: for a WebSocket client and append token through URL query encoding. Never substitute your personal REST API key. A room token is tied to a document and admits a connection within ten minutes; it is not a REST credential. Fetch a fresh admission on reconnect. Avoid logging full socket URLs.
Admission can fail with 402 plan_required, 403 policy_denied/forbidden, 404 not_found, or 503 live_unavailable. The upgrade rejects invalid or expired room tokens with HTTP 401. Paths that are not supported WebSocket upgrades return 404; room loading can return 503. Handle HTTP failure before assuming a socket exists.

Wire encoding#

Each WebSocket binary message carries one protocol frame. Encoding uses lib0/encoding: unsigned variable-length integers, length-prefixed byte arrays (varUint8Array), and length-prefixed UTF-8 strings (varString). The first value is the message type. There is no JSON envelope and no subprotocol negotiation requirement.
TypeNameDirection and body
0SYNCBoth ways: varUint step, varUint8Array payload. Step 0 = Yjs state vector, 1 = state update in reply, 2 = incremental update.
1AWARENESSBoth ways: varUint8Array containing a y-protocols awareness update.
2STATUSServer → client: varString containing JSON {type, role?, reason?}. Types: welcome, readonly, role, denied.
3QUERYClient → server, empty body: request current awareness states.
4PINGClient → server, empty body.
5PONGServer → client, empty body, in response to PING.
6TEXTClient sends empty body to subscribe to full text; server responds with varString full text and sends subsequent text changes.
7SPLICEClient → server: varUint index, varUint deleteCount, varString insert; UTF-16 positions in the current text.
Unsigned varints use seven data bits per byte with a continuation high bit. A byte array starts with its byte length; a string starts with the UTF-8 byte length, followed by those bytes. For example, PING is hex 04, PONG is 05, QUERY is 03, TEXT subscription is 06, full text Hi is 06 02 48 69, and inserting A at index 0 is 07 00 00 01 41.
The shared Yjs text is doc.getText("content"). A Yjs client should process the server's welcome, initial sync step 1 (wire step value 0) and awareness. Send its own state vector to receive missing server state; an editor can answer the server's state vector with its missing updates. Apply received changes with a distinct remote origin to prevent echo loops, and send local editor changes as SYNC update frames. Viewers must not send step 2/update frames, even empty updates, because those are treated as writes.
Awareness payloads contain varUint count followed by varUint clientId, varUint clock, varString JSON state per entry (null removes presence). User display names/colours/roles are server-controlled from admission. There are at most four awareness client IDs per connection. Remove local awareness when leaving and clear stale peer state when reconnecting.
The type numbers 0–3 resemble y-websocket, but Handwrite's status and extensions are custom. Do not assume an unmodified y-websocket provider is fully compatible. Use a protocol adapter with the frame definitions above. For concurrent editors, Yjs updates preserve collaborative intent; positional SPLICE has no base revision or operation acknowledgment and must not be treated as an offline conflict-resolution protocol.

Roles, failures and recovery#

On join, the server sends STATUS welcome with the current role. editor can send updates and splices; viewer can sync/read and publish permitted presence. Update the UI on STATUS role. A viewer's first write is dropped and returns STATUS readonly; a second closes the connection. STATUS denied means stop editing and refresh access, not reconnect forever.
Close codeMeaningRecovery
1009Text frame or binary message larger than 2 MiBCorrect framing or split updates; do not retry the same oversized frame.
4400Malformed protocol frame or Yjs updateFix the encoder; retain local work for diagnosis/recovery.
4003Repeated write from a viewerSwitch to read-only; do not replay writes.
4403Access revokedStop and recheck document access.
The decoder returns no message for unknown/malformed types, and the room closes with 4400. Text frames are rejected even when their text is valid JSON. PING/PONG are application frames, distinct from native WebSocket control frames; no fixed client heartbeat interval is mandated by this contract.
After a transient disconnect, use bounded backoff with jitter, acquire a new admission, and resync using Yjs state vectors. Do not blindly replay positional splices. Do not infer persistence acknowledgment from socket send success or PONG: the protocol has no per-edit durable-commit acknowledgment. Preserve unsynced local edits and show connection state.
While a live room is active, REST text replacement can return 409 live_session_active. Use the socket for text changes, and re-fetch metadata/revision after leaving before using conditional REST edits. The implementation periodically snapshots dirty state (approximately 30 seconds), flushes on closing, and periodically rechecks access; these timings are implementation behavior, not a persistence SLA.

Verification boundary#

This contract was checked against the server/client codec and room implementation on 2026-10-26. Protocol fixture and room tests passed. This documentation work did not establish an authenticated live room or verify deployment/provider configuration. Consumers should run a two-client test with an authorized test document: editor changes, viewer sync, role downgrade, disconnect/rejoin, Unicode offsets and revoked access.
Modified at 2026-10-26 12:58:50
Previous
API specification notes
Next
README — GET /api/v1/me
Built with