Gryt
Build on Gryt

Server API

Every REST endpoint and Socket.IO event the server answers

Two surfaces. REST handles uploads, downloads and a few things that want plain HTTP caching. Everything interactive runs over Socket.IO: chat, presence, voice signalling, moderation, settings.

There are no authentication endpoints here. Identity comes from an account issuer or from a keypair on the device, and joining is a signed challenge over the socket. See Identity.

REST

Default port 5000. Endpoints marked Bearer want Authorization: Bearer <accessToken>.

Public

MethodPathReturns
GET/health{ status, service, serverName, timestamp }
GET/infoThe join preview. See below
GET/iconThe server icon. 404 when none is set
GET/metricsPrometheus, text/plain

What /info returns

{
  "serverId": "...",
  "name": "...",
  "description": "...",
  "members": "12",
  "lanOpen": false,
  "identityTiers": ["account"],
  "joinPolicy": "invite"
}

identityTiers and joinPolicy are deliberately unauthenticated. The point is that a client can say "you don't need an account for this one" before anybody tries, and neither field tells a caller anything they could not learn by attempting the join and reading the refusal.

Two things depend on who is asking. A server whose discoverable setting is off answers 404 to anybody who isn't already a member, so it disappears rather than advertising itself. And version is only included for members, because a precise build number lets anyone on the network scan for hosts running a version with a known vulnerability, and this endpoint has to stay open for the add-server flow.

No JWKS endpoint

The server doesn't publish /.well-known/jwks.json. It fetches one, from the identity issuer it's configured to trust, and caches it. Nothing here serves keys.

Messages

MethodPathAuthNotes
GET/api/messages/:conversationIdBearer?limit= up to 200, ?before= an ISO date

Sending is chat:send over the socket. There's no POST here.

Uploads

MethodPathAuthNotes
POST/api/uploadsBearermultipart file
POST/api/uploads/avatarBearermultipart
DELETE/api/uploads/avatarBearer
POST/api/uploads/group-iconBearermultipart file, a group's picture. Returns fileId for dm:group:update and doesn't change your avatar
POST/api/uploads/webhook-avatarBearermultipart file, a webhook's avatar. Needs Manage webhooks. Returns fileId for the webhook's avatar_file_id and doesn't change your avatar
GET/api/uploads/files/:fileIdSigned link?thumb=1 for the thumbnail, ?download=1 for an attachment header

A file URL can't carry a header, because most of them end up in an <img src>. So each one is signed for one file, and it stops working after a few minutes. The server hands out the key it's signed with in fileKey, on server:joined, token:refreshed and file:key:

{ "key": "<base64url, 32 bytes>", "user": "<serverUserId>", "until": 1790043200, "now": 1790000000000 }

until is when the key expires, in seconds. now is the server's clock in milliseconds, so you can sign against its time rather than yours. To sign a URL:

  1. Pick expires, in seconds. It can be at most ten minutes ahead of the server's clock, and no later than until.
  2. Sign file-url, the file id, thumb or full, and expires, joined with \n, using HMAC-SHA256 with the decoded key.
  3. Add u (the user), k (until), e (expires) and s (the signature, base64url with no padding) to the query string.
/api/uploads/files/<fileId>?thumb=1&u=<user>&k=<until>&e=<expires>&s=<signature>

A link for one file won't open another, and a thumbnail link won't give you the full file. When the server's or the member's token version moves, every link signed with the old key stops working too. The server still takes the older ?t=<fileToken> for now, and will stop in a later release.

Server

MethodPathAuthNotes
POST/api/server/iconBearerOwner only, multipart file
DELETE/api/server/iconBearerOwner only

GRYT_SERVER_ICON_MAX_MB caps the upload, 25 MB by default. PNG, JPEG, WebP, GIF, AVIF and SVG are accepted; SVG is sanitised before it's stored.

Members

MethodPathAuthReturns
GET/api/membersBearer{ items: [{ serverUserId, nickname, lastSeen }] }

Emojis

MethodPathAuthNotes
GET/api/emojis—[{ name, file_id }]
GET/api/emojis/all—Including staged
GET/api/emojis/img/:name—Image bytes, cached
POST/api/emojisBearerAdmin or owner. file + name, or files + a names JSON array
PATCH/api/emojis/:nameBearerRename. Body { name }
DELETE/api/emojis/:nameBearer
GET/api/emojis/queueBearerThe processing queue
POST/api/emojis/stageBearerStage emojis before committing them

Importing emojis

From BetterTTV:

MethodPathAuth
GET/api/emojis/bttv/user/:userId—
GET/api/emojis/bttv/emote/:emoteId—
GET/api/emojis/bttv/file/:emoteId—
POST/api/emojis/bttv/importBearer, admin or owner, 200 per request

From emoji.gg:

MethodPathAuth
GET/api/emojis/emojigg/user/:username—
GET/api/emojis/emojigg/pack/:slug—
GET/api/emojis/emojigg/emoji/:slug—
GET/api/emojis/emojigg/file—

The file routes fetch the image server-side and return the bytes, so the browser isn't the thing talking to BetterTTV or emoji.gg.

MethodPathAuthNotes
GET/api/link-preview?url=BearerOpen Graph metadata
GET/api/oembed?url=BearerX, SoundCloud, Spotify, TikTok
GET/api/media/metadata?url=BearerDimensions and MIME type

Webhooks

The payload, its limits and its errors are on Webhooks.

MethodPathAuthNotes
POST/api/webhooks/:webhookId/:tokenToken in the pathPost as the webhook
GET/api/webhooksBearerList
POST/api/webhooksBearerCreate
GET/api/webhooks/:webhookIdBearerRead one
PATCH/api/webhooks/:webhookIdBearerUpdate
DELETE/api/webhooks/:webhookIdBearerDelete

The management API

A separate listener on GRYT_ADMIN_PORT (5099), started only when GRYT_ADMIN_TOKEN is set, published to loopback only. It exists so somebody administering the machine can change database-backed settings without being the server's owner.

MethodPathReturns
GET/management/settingsjoinPolicy, discoverable, lanOpen, profanityMode
PATCH/management/settingsThe same, updated. Send only the keys you're changing
GET/management/health{ ok: true }

Authorization: Bearer <GRYT_ADMIN_TOKEN> on all three. Requires server 1.5.0. A PATCH goes through the same apply path as a change made from a client, so it withdraws mDNS advertising, drops caches and writes an audit entry rather than only updating a row.

Socket.IO

Joining

The handshake is three messages, and it's a signature exchange rather than a password.

EventDirectionPayload
server:infoclient → server—
server:joinclient → server{ nickname?, inviteCode? }
server:challengeserver → clientA nonce to sign
server:verifyclient → server{ certificate, assertion, link? }
server:joinedserver → clientSuccess, with tokens, and identityClaim if a link came with the join
server:setup_requiredserver → clientNothing has claimed ownership yet

A signed-in client that used to be a guest on this server can send link, a proof signed with the guest's key. The server moves the guest's membership to the account. If the account is already a member, it merges the guest into the account's membership instead. identityClaim on server:joined says what happened: carried, merged, no_prior_membership or failed.

The server also proves itself, over server:identify, as a connection-level exchange rather than part of the join. A client reconnecting with a saved token never joins at all, so a proof carried on join would leave the common path handing a bearer token to a server nobody authenticated.

Refusals come back on server:error with a reason: invite_required, invalid_invite, invite_rate_limited, identity_tier_refused, identity_verification_failed, challenge_expired, nonce_mismatch, approval_pending, auth_required, auth_disabled, auth_misconfigured, join_refused, join_failed, invalid_nickname.

Sessions and tokens

EventDirection
session:restoreclient → server
token:refreshclient → server
token:refreshed, token:invalid, token:revoked, token:errorserver → client
file:keyserver → client

A restored session gets no token:refreshed, so file:key brings it the key for signing file links. It's the same shape as fileKey above.

Chat

EventDirectionNotes
chat:sendclient → server
chat:fetchclient → server
chat:edit, chat:delete, chat:reactclient → server
chat:reportclient → server{ accessToken, conversationId, messageId, mls? }. mls is for an MLS message, see Reports
chat:typing, chat:stop_typingboth
chat:new, chat:edited, chat:deleted, chat:reactionserver → client
chat:historyserver → clientAnswer to chat:fetch
chat:purge_userserver → clientEverything from one member is gone
chat:merge_userserver → clientA guest was merged into an account: { from_server_user_id, to_server_user_id }
chat:pinclient → server{ accessToken, conversationId, messageId, pinned }
chat:pinsbothAsk with { conversationId }, and the answer comes back on the same name
chat:pinnedserver → clientA message was pinned or unpinned
chat:errorserver → client

Mentions

EventDirectionNotes
mentions:list, mentions:seenclient → server
mentions:listserver → clientAnswer to both, with the unseen mentions and counts per channel and thread
mention:newserver → clientYou were mentioned while connected

Blocking, and who can reach you

EventDirectionNotes
user:block, user:unblockclient → server{ accessToken, serverUserId }
user:blocks:listclient → server
user:blocked, user:unblocked, user:blocksserver → client
contact:prefs:get, contact:prefs:setclient → serverWho may message or call you, everyone, friends or nobody
contact:prefsserver → clientAnswer to both
contact:reachableclient → serverUp to 500 member ids. Asks which of them a new DM or group add would reach
contact:reachableserver → clientThe ones that would, plus anyone you already have a conversation with. Never yourself

Direct messages

A conversation between two people, or a group. dm:open is idempotent — asking twice for the same pair returns the conversation that already exists.

Hiding a conversation happens on the device, so there's no event for it. The server lists every conversation you're in and leaves the drawing to the client.

EventDirectionNotes
dm:openclient → server{ accessToken, targetServerUserId }
dm:listbothRequest takes { accessToken }; the answer comes back under the same name
dm:group:createclient → server{ accessToken, memberIds, name?, iconFileId? }
dm:group:addclient → server{ accessToken, conversationId, targetServerUserId }
dm:group:updateclient → server{ accessToken, conversationId, name?, iconFileId? } — null clears
dm:group:leaveclient → server{ accessToken, conversationId }
dm:key:publishclient → server{ binding? } — the key binding others need to write to you
dm:openedserver → clientThe conversation, whether it was new or already there
dm:leftserver → client
dm:errorserver → client

Encrypted direct messages (MLS)

The server is the delivery service for MLS (RFC 9420). It keeps KeyPackages and Welcomes, puts commits in order and passes messages on. It never gets a key. The only parts of a message it reads are the group id, the epoch and what kind of message it is. Only one-to-one DMs use it so far.

A server that has it says so in server:info, as mls: { version, ciphersuites, retentionDays, reports }. A client shouldn't use any of this on a server that doesn't. reports is true once the server takes a report of an MLS message.

Every request takes accessToken and answers through the Socket.IO acknowledgement, with { ok: true, ... } or { ok: false, error, message }. MLS bytes go as binary both ways. socket.io closes the connection on a packet with more than ten binary parts, so keep what you send to ten, and the server keeps its replies to ten too. So a publish carries ten KeyPackages at most, last resort included, and the replies below that could run longer are paged.

EventDirectionNotes
mls:person:publishclient → server{ binding }. Your person key binding, or null to take it back. Send dm:key:publish first
mls:keypackages:publishclient → server{ deviceId, keyPackages, lastResort? }. The first publish registers the device. Five devices per member. See KeyPackages for what gets refused
mls:keypackages:claimclient → server{ conversationId, deviceId, devices? }. One KeyPackage for each device, and each is handed out once. Expired ones never are. Nine devices a reply at most, and more lists the rest to name in devices next time
mls:devicesclient → server{ conversationId? }. Your own devices, or everybody's in the conversation
mls:device:removeclient → server{ deviceId }, one of your own. That id can't come back: see Removed devices
mls:group:createclient → server{ conversationId, groupId }. The first group for a conversation wins, and the other side gets group_exists
mls:commitclient → server{ conversationId, deviceId, commit, welcome? }. One commit per epoch. A commit for any other epoch gets stale_epoch with the current one
mls:sendclient → server{ conversationId, deviceId, message, placeholder?, attachmentIds? }. Application messages and proposals. Pass placeholder: false for anything that isn't a message, like a reaction, an edit or a delete (see Apps from before MLS). attachmentIds lists the uploads the message carries (see Files)
mls:log:fetchclient → server{ conversationId, after, limit? }. The log after a cursor, ten entries a page at most. gap means some of what you missed has already been dropped
mls:syncclient → server{ deviceId }. Your groups, your oldest nine waiting Welcomes, and how many KeyPackages you have left. moreWelcomes says there are more. Ack the ones you've joined and sync again for the next nine
mls:welcome:ackclient → server{ deviceId, welcomeIds }, once a Welcome is saved
mls:messageserver → clientA new entry in a group's log
mls:welcomeserver → clientA Welcome for one of your devices
mls:devices:changedserver → client{ serverUserId }. Somebody you share a DM with added or removed a device

The server keeps the log for 30 days, or fewer if whoever runs it sets MLS_RETENTION_DAYS.

A person key binding has to be signed by the same identity key, for the same scope, as the DM key binding you published. That's the key people have already pinned for you. Anything else gets wrong_identity. If there's no DM key binding yet you get no_dm_key, and since dm:key:publish has no acknowledgement, the person key can occasionally land first. Retry once.

Once it's stored, the binding shows up as personKeyBinding on your row in members:list, next to dmKeyBinding. It's null for anybody who hasn't published one.

Removed devices

Once you remove a device, the server remembers its id for good. Anything that device sends afterwards gets device_removed, whatever the event: sync, publish, claim, commit, send or a Welcome ack. It can't publish KeyPackages under that id again, so nobody can add it back to a DM.

An app that gets device_removed should stop using MLS on that server and delete its MLS state there. The Gryt apps also delete the encrypted DMs they hold from that server. They won't set up a new device there until you sign in again, or restore your identity from its recovery key if you don't have an account. A new device gets a new id, and that registers as usual.

KeyPackages

A KeyPackage lasts 30 days, from notBefore to notAfter, and both ends count. The server checks that against its own clock. On upload it refuses a package that:

  • isn't signed by its own leaf key (invalid_key_package)
  • doesn't carry a device certificate its person key signed for that leaf key (invalid_device_certificate)
  • has a certificate naming a different device from the deviceId it's uploaded under (device_mismatch)
  • claims more than 30 days (key_package_lifetime), has expired (key_package_expired), or hasn't started yet (key_package_not_yet_valid)

The three lifetime refusals include serverTime, in seconds, so a device whose clock is off can make its packages again against the server's time.

The server doesn't check the certificate's scope, or whose person key signed it. Clients check both.

A claim only hands out a package with at least an hour left, and the count in mls:sync leaves out the ones that don't qualify, so a device knows to top up. Expired packages are pruned every hour.

For now the server also takes ts-mls's default lifetime (0 to 2^63-1), which @gryt/crypto 0.6.0 and older write. It keeps those for 30 days from upload.

Files

A file in an MLS DM is uploaded the usual way, encrypted by the sender's app first. The key goes inside the message, where the server can't see it. What the server does need is to know the message holds the upload. Otherwise the media sweep deletes it 30 minutes after it went up, like any upload nothing references.

So mls:send takes attachmentIds, the upload ids the message carries. Only an application message can have them, and placeholder: false doesn't matter. They're checked the way a sealed DM's attachments are:

  • the sender needs attach_files here (forbidden)
  • ten at most (too_many_attachments)
  • each one a file the sender can already read (attachment_not_found)
  • each one under the server's upload cap (attachment_too_large)

After that the other person can fetch them, and the media sweep leaves them alone. They go when the log entry goes: after the retention window, or with the conversation. Then any of them nothing else holds gets deleted, the same as a deleted message's files.

Reports

The server never has an MLS message, so it can't hand a moderator the one being reported. The reporter's app sends its own copy instead, as mls on chat:report:

mls: { senderServerUserId: string; text: string }

messageId is the id inside the message. The server can't check the copy, so it goes into the queue marked unverified: true in reports:list, and a client should say so on the card. It does check that:

  • the reporter and senderServerUserId are both in the conversation, and aren't the same person. Anything else gets "Message not found", the same answer as a conversation that doesn't exist
  • the reporter has report_messages, and hasn't already reported that message
  • text is at most 32,000 characters and messageId at most 64

It's rate-limited like any other report. Files in the message aren't sent.

Resolving one with delete closes the report and deletes nothing, since there's nothing on the server to delete. delete_all_and_ban still deletes what the sender has on the server and bans them.

Apps from before MLS

An app from before MLS doesn't know mls:message. So for every application message, the server also writes a line into the DM for those apps, from the system sender:

@Kari sent an end-to-end encrypted message. Update Gryt to read it.

It arrives as an ordinary chat:new, and it's in the history, with one extra field:

mls_placeholder: { seq: number; sender_server_id: string }

seq is the entry in the group's log that the line stands in for, and sender_server_id is who really sent it. An app that reads MLS should drop any message with mls_placeholder, and count unread from mls:message instead.

The sender's own sockets don't get the line live. Their older apps see it in the history. Nobody who blocked the sender gets it.

Calls

Ringing only. The call itself is voice, and goes through the events above.

EventDirectionNotes
call:ringclient → server{ accessToken, conversationId }
call:decline, call:cancelclient → server{ accessToken, conversationId }
call:incomingserver → clientTo everybody else in the conversation
call:ringingserver → clientTo the caller, confirming it went out
call:withdrawnserver → clientDeclined, cancelled, or answered elsewhere
call:errorserver → client

Threads

A thread hangs off one message. thread:create names the message it grows from.

EventDirectionNotes
thread:createclient → server{ conversationId, rootMessageId, accessToken, title? }
thread:fetchclient → server{ conversationId, threadId, limit?, before? }
thread:status:setclient → server{ conversationId, threadId, status, accessToken }
thread:tags:setclient → server{ conversationId, threadId, tagIds, accessToken }
thread:created, thread:updated, thread:deletedserver → client
thread:historyserver → clientAnswer to thread:fetch
thread:errorserver → client

Forums

A forum channel is threads with a topic list in front of them, so the events here sit on top of the thread events above.

EventDirectionNotes
forum:topicsclient → server{ conversationId }
forum:topic:createclient → server{ conversationId, title, text?, accessToken, tagIds? }
forum:topics:listserver → clientAnswer to forum:topics
forum:topic:createdserver → clientArrives with a thread:created for the same topic
forum:errorserver → client

Voice

EventDirectionNotes
voice:room:requestclient → serverAsks for access to a voice channel
voice:room:grantedserver → client{ room_id, join_token, sfu_url }
voice:channel:joinedboth
voice:state:updateclient → serverMute, deafen
voice:stream:set, voice:camera:state, voice:screen:state, voice:framing:setclient → server
voice:peer:connected, voice:peer:disconnectedclient → server
voice:peer:joined, voice:peer:leftserver → client
voice:latency:reportclient → server
voice:latency:updateserver → clientEverybody's round-trip times
voice:disconnect:userclient → serverModeration
voice:kicked, voice:device:disconnect, voice:room:leaveserver → client
voice:state:restoredserver → clientAfter a reconnect
voice:error, voice:room:errorserver → client

Members, profile and presence

EventDirection
members:fetchclient → server
members:list, members:errorserver → client
profile:updateclient → server
profile:updated, profile:errorserver → client
avatar:updatedclient → server
presence:heartbeatclient → server
presence:staleserver → client

Push notifications

The phone gets a capability from the push relay at push.gryt.chat and hands it over with push:register. It only means something to the relay. The server never sees the Apple or Google token behind it. push:presence says whether the app is in the background, and the server only wakes a phone when nobody's at a screen. Both register calls answer through the acknowledgement, with { ok: true } or { ok: false, error }. A server with GRYT_PUSH_RELAY_URL=off answers push_off.

push:register also carries the phone's notification settings for that server, since the server decides what to send. muted and all are lists of conversation ids, and everyone says whether @everyone and @here get through. A DM pushes unless it's muted. A channel in all pushes for every message, any other channel only for a mention of you or one of your roles, and @everyone and @here only when everyone is true. A muted conversation never pushes. A phone that sends none of these gets DMs and direct mentions only.

previewKey is 32 random bytes, base64url, that the phone made for this server. With it, each push carries a preview the relay can't read: the server seals {"t": title, "s": subtitle, "b": body} with AES-256-GCM, and sends 0x01, a 12-byte nonce, the ciphertext and the 16-byte tag as base64url. The additional data is gryt-push-1| followed by the capability's tag, the first 16 hex characters of its SHA-256. Title is the sender, subtitle is #channel · server (just the server for a DM), and body is the first line. An MLS DM's body stays "New direct message", since the server can't read it.

EventDirection
push:register, push:unregisterclient → server
push:presenceclient → server

Moderation

EventDirection
server:kick, server:ban, server:unbanclient → server
server:mute, server:deafenclient → server
server:bans:listclient → server
server:kicked, server:muted, server:deafenedserver → client
server:kick:success, server:ban:success, server:unban:success, server:mute:success, server:deafen:successserver → client
server:bansserver → client
reports:list, reports:resolveclient → server
reports:list, reports:resolved, report:submitted, report:already_reportedserver → client

Administration

A channel inside a folder follows the folder's permission scope for as long as it has none of its own — set one with server:channels:scope:set and it sticks, even Everyone. Pull the channel out of the folder, or delete the folder, and it keeps whatever scope it was reading at that moment as its own rather than falling back to Everyone.

EventDirectionNotes
server:settings:get, server:settings:updateclient → serverThe database-backed settings
server:settingsserver → client
server:channels:list, :upsert, :delete, :reorderclient → server
server:channelsserver → client
server:channels:scope:get, server:channels:scope:setclient → serverNeeds manage_channels. Who can use a channel: Everyone, a template or its own rules
server:channels:scope:followclient → serverNeeds manage_channels. Drops the channel's own rules and goes back to following its folder
server:channels:scopeserver → clientAnswer to all three above. Carries followsFolder and, when it's following one, the folder's id and name
server:folders:scope:get, server:folders:scope:setclient → serverNeeds manage_channels. Same, for a folder — applies to every channel inside it that has no rules of its own
server:folders:scopeserver → clientAnswer to both above. Carries channelCount, how many channels are following the folder
server:permissions:templates:list, server:permissions:template:save, server:permissions:template:deleteclient → serverNamed sets of channel permissions that several channels can share
server:permissions:templatesserver → client
server:sidebar:list, :item:upsert, :item:delete, :reorderclient → server
server:sidebarserver → clientLeaves out any channel the caller can't see, and a folder left with none of its channels in it — unless the caller holds manage_channels, which shows everything
server:roles:list, server:roles:setclient → server
server:roles, server:role:updatedserver → client
server:invites:create, :list, :revokeclient → server
server:invites, server:invite:created, server:invite:revokedserver → client
server:member:inviteboth
server:joinRequests:list, server:joinRequests:decideclient → server
server:joinRequests, server:joinRequest:decidedserver → client
server:audit:listclient → server
server:auditserver → client
server:user:replaceclient → server
server:user:replace:successserver → client
server:leaveclient → server
server:leftserver → client
server:detailsboth
server:clientsserver → client
server:emojiQueue:getclient → server
server:emojiQueue:state, server:emojis:updatedserver → client
server:version:checkclient → server
server:version:statusserver → client

Diagnostics

EventDirection
diagnostics:pingclient → server
diagnostics:pongserver → client

On this page