Everything push.tt does is reachable from the same API our own console and Android client are built on. This page is the technical shape of it — auth, the endpoints, the voice wire format and the encryption — so you can judge feasibility before writing anything.
Username and password to a bearer token. Tokens are per device, so revoking one handset does not sign out the rest.
POST /api/v1/auth/register — create an account and enrol a device keyPOST /api/v1/auth/login — returns a bearer token bound to a deviceGET /api/v1/me — the signed-in user, their org and roleGET /api/v1/devices · DELETE /api/v1/devices/:id — list and revoke device keysAuthorization: Bearer <token>Versioned under /api/v1. This is the same surface the console uses, which is the only durable guarantee that it keeps working.
GET/POST /channels, /channels/join, /channels/:id/members, /channels/:id/grant-keyPOST /messages/text, /messages/media, /messages/read, GET /messages/history, DELETE /messages/:id/org/users, /org/stats, /org/audit, /org/settings, /org/vault/*/store/catalogue, /store/licences, /store/esims, /store/walletGET /emergency/active plus raise, acknowledge and clearSettings that change who can read what answer HTTP 428 until an admin sends an explicit acknowledgement. That is not a validation error to retry past — it is a consent gate, and your integration has to surface the warning rather than resending with a flag flipped.
One socket at /ws carries both JSON control messages and binary audio. Presence, occupancy, floor control, emergencies and live voice all share it.
hello to authenticate the socket, then ready comes back with channels, unread counts and any active emergencychannel/connect and channel/disconnect — occupancy, the "N connected" counttalk/start → the floor is granted or talk/busy names the current speaker; talk/end closes the streamstatus, typing, read — presence and conversation stateping/pong carry timestamps, which is what the clients use to grade link qualityDocumented because an integration that carries audio has to get these exactly right, and they are the parts people most often assume.
[type u8 | streamId u32le | seq u16le]opus/16000 is preferred and pcm16/16000 is the fallback — never assume a stream matches your own device's capability, read it from stream/startu16le per frame. The server concatenates payloads and records no frame boundaries, so without that prefix a stored Opus blob is unparseableIf you are writing a client rather than calling the API, this is the part that has to match byte for byte. Three implementations already agree — TypeScript, Kotlin and Swift — and a round-trip test is what keeps them that way.
ephPub(32) ‖ nonce(12) ‖ ciphertext‖tag. Note CryptoKit's .combined includes the nonce and is not this layoutu32le seq, with the prefix travelling in talk/start. Never reuse a (key, nonce) pair — the one GCM rule that does not bendListed so you can plan around it rather than discover it.
packages/protocol is shared TypeScript the clients build from, and we will send itThe question that decides most of the design: does your integration need message content, or only the fact of a message? Content means holding a key and decrypting client-side, because no server-side call will ever return plaintext for an encrypted channel. Everything else is straightforward.