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
| Method | Path | Returns |
|---|---|---|
| GET | /health | { status, service, serverName, timestamp } |
| GET | /info | The join preview. See below |
| GET | /icon | The server icon. 404 when none is set |
| GET | /metrics | Prometheus, 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
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/messages/:conversationId | Bearer | ?limit= up to 200, ?before= an ISO date |
Sending is chat:send over the socket. There's no POST here.
Uploads
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/uploads | Bearer | multipart file |
| POST | /api/uploads/avatar | Bearer | multipart |
| DELETE | /api/uploads/avatar | Bearer | |
| POST | /api/uploads/group-icon | Bearer | multipart file, a group's picture. Returns fileId for dm:group:update and doesn't change your avatar |
| POST | /api/uploads/webhook-avatar | Bearer | multipart 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/:fileId | Signed 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:
- Pick
expires, in seconds. It can be at most ten minutes ahead of the server's clock, and no later thanuntil. - Sign
file-url, the file id,thumborfull, andexpires, joined with\n, using HMAC-SHA256 with the decoded key. - Add
u(theuser),k(until),e(expires) ands(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
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/server/icon | Bearer | Owner only, multipart file |
| DELETE | /api/server/icon | Bearer | Owner 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
| Method | Path | Auth | Returns |
|---|---|---|---|
| GET | /api/members | Bearer | { items: [{ serverUserId, nickname, lastSeen }] } |
Emojis
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/emojis | — | [{ name, file_id }] |
| GET | /api/emojis/all | — | Including staged |
| GET | /api/emojis/img/:name | — | Image bytes, cached |
| POST | /api/emojis | Bearer | Admin or owner. file + name, or files + a names JSON array |
| PATCH | /api/emojis/:name | Bearer | Rename. Body { name } |
| DELETE | /api/emojis/:name | Bearer | |
| GET | /api/emojis/queue | Bearer | The processing queue |
| POST | /api/emojis/stage | Bearer | Stage emojis before committing them |
Importing emojis
From BetterTTV:
| Method | Path | Auth |
|---|---|---|
| GET | /api/emojis/bttv/user/:userId | — |
| GET | /api/emojis/bttv/emote/:emoteId | — |
| GET | /api/emojis/bttv/file/:emoteId | — |
| POST | /api/emojis/bttv/import | Bearer, admin or owner, 200 per request |
From emoji.gg:
| Method | Path | Auth |
|---|---|---|
| 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.
Link previews and media
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/link-preview?url= | Bearer | Open Graph metadata |
| GET | /api/oembed?url= | Bearer | X, SoundCloud, Spotify, TikTok |
| GET | /api/media/metadata?url= | Bearer | Dimensions and MIME type |
Webhooks
The payload, its limits and its errors are on Webhooks.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/webhooks/:webhookId/:token | Token in the path | Post as the webhook |
| GET | /api/webhooks | Bearer | List |
| POST | /api/webhooks | Bearer | Create |
| GET | /api/webhooks/:webhookId | Bearer | Read one |
| PATCH | /api/webhooks/:webhookId | Bearer | Update |
| DELETE | /api/webhooks/:webhookId | Bearer | Delete |
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.
| Method | Path | Returns |
|---|---|---|
| GET | /management/settings | joinPolicy, discoverable, lanOpen, profanityMode |
| PATCH | /management/settings | The 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.
| Event | Direction | Payload |
|---|---|---|
server:info | client → server | — |
server:join | client → server | { nickname?, inviteCode? } |
server:challenge | server → client | A nonce to sign |
server:verify | client → server | { certificate, assertion, link? } |
server:joined | server → client | Success, with tokens, and identityClaim if a link came with the join |
server:setup_required | server → client | Nothing 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
| Event | Direction |
|---|---|
session:restore | client → server |
token:refresh | client → server |
token:refreshed, token:invalid, token:revoked, token:error | server → client |
file:key | server → 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
| Event | Direction | Notes |
|---|---|---|
chat:send | client → server | |
chat:fetch | client → server | |
chat:edit, chat:delete, chat:react | client → server | |
chat:report | client → server | { accessToken, conversationId, messageId, mls? }. mls is for an MLS message, see Reports |
chat:typing, chat:stop_typing | both | |
chat:new, chat:edited, chat:deleted, chat:reaction | server → client | |
chat:history | server → client | Answer to chat:fetch |
chat:purge_user | server → client | Everything from one member is gone |
chat:merge_user | server → client | A guest was merged into an account: { from_server_user_id, to_server_user_id } |
chat:pin | client → server | { accessToken, conversationId, messageId, pinned } |
chat:pins | both | Ask with { conversationId }, and the answer comes back on the same name |
chat:pinned | server → client | A message was pinned or unpinned |
chat:error | server → client |
Mentions
| Event | Direction | Notes |
|---|---|---|
mentions:list, mentions:seen | client → server | |
mentions:list | server → client | Answer to both, with the unseen mentions and counts per channel and thread |
mention:new | server → client | You were mentioned while connected |
Blocking, and who can reach you
| Event | Direction | Notes |
|---|---|---|
user:block, user:unblock | client → server | { accessToken, serverUserId } |
user:blocks:list | client → server | |
user:blocked, user:unblocked, user:blocks | server → client | |
contact:prefs:get, contact:prefs:set | client → server | Who may message or call you, everyone, friends or nobody |
contact:prefs | server → client | Answer to both |
contact:reachable | client → server | Up to 500 member ids. Asks which of them a new DM or group add would reach |
contact:reachable | server → client | The 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.
| Event | Direction | Notes |
|---|---|---|
dm:open | client → server | { accessToken, targetServerUserId } |
dm:list | both | Request takes { accessToken }; the answer comes back under the same name |
dm:group:create | client → server | { accessToken, memberIds, name?, iconFileId? } |
dm:group:add | client → server | { accessToken, conversationId, targetServerUserId } |
dm:group:update | client → server | { accessToken, conversationId, name?, iconFileId? } — null clears |
dm:group:leave | client → server | { accessToken, conversationId } |
dm:key:publish | client → server | { binding? } — the key binding others need to write to you |
dm:opened | server → client | The conversation, whether it was new or already there |
dm:left | server → client | |
dm:error | server → 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.
| Event | Direction | Notes |
|---|---|---|
mls:person:publish | client → server | { binding }. Your person key binding, or null to take it back. Send dm:key:publish first |
mls:keypackages:publish | client → server | { deviceId, keyPackages, lastResort? }. The first publish registers the device. Five devices per member. See KeyPackages for what gets refused |
mls:keypackages:claim | client → 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:devices | client → server | { conversationId? }. Your own devices, or everybody's in the conversation |
mls:device:remove | client → server | { deviceId }, one of your own. That id can't come back: see Removed devices |
mls:group:create | client → server | { conversationId, groupId }. The first group for a conversation wins, and the other side gets group_exists |
mls:commit | client → server | { conversationId, deviceId, commit, welcome? }. One commit per epoch. A commit for any other epoch gets stale_epoch with the current one |
mls:send | client → 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:fetch | client → 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:sync | client → 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:ack | client → server | { deviceId, welcomeIds }, once a Welcome is saved |
mls:message | server → client | A new entry in a group's log |
mls:welcome | server → client | A Welcome for one of your devices |
mls:devices:changed | server → 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
deviceIdit'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_fileshere (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
senderServerUserIdare 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 textis at most 32,000 characters andmessageIdat 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.
| Event | Direction | Notes |
|---|---|---|
call:ring | client → server | { accessToken, conversationId } |
call:decline, call:cancel | client → server | { accessToken, conversationId } |
call:incoming | server → client | To everybody else in the conversation |
call:ringing | server → client | To the caller, confirming it went out |
call:withdrawn | server → client | Declined, cancelled, or answered elsewhere |
call:error | server → client |
Threads
A thread hangs off one message. thread:create names the message it grows from.
| Event | Direction | Notes |
|---|---|---|
thread:create | client → server | { conversationId, rootMessageId, accessToken, title? } |
thread:fetch | client → server | { conversationId, threadId, limit?, before? } |
thread:status:set | client → server | { conversationId, threadId, status, accessToken } |
thread:tags:set | client → server | { conversationId, threadId, tagIds, accessToken } |
thread:created, thread:updated, thread:deleted | server → client | |
thread:history | server → client | Answer to thread:fetch |
thread:error | server → 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.
| Event | Direction | Notes |
|---|---|---|
forum:topics | client → server | { conversationId } |
forum:topic:create | client → server | { conversationId, title, text?, accessToken, tagIds? } |
forum:topics:list | server → client | Answer to forum:topics |
forum:topic:created | server → client | Arrives with a thread:created for the same topic |
forum:error | server → client |
Voice
| Event | Direction | Notes |
|---|---|---|
voice:room:request | client → server | Asks for access to a voice channel |
voice:room:granted | server → client | { room_id, join_token, sfu_url } |
voice:channel:joined | both | |
voice:state:update | client → server | Mute, deafen |
voice:stream:set, voice:camera:state, voice:screen:state, voice:framing:set | client → server | |
voice:peer:connected, voice:peer:disconnected | client → server | |
voice:peer:joined, voice:peer:left | server → client | |
voice:latency:report | client → server | |
voice:latency:update | server → client | Everybody's round-trip times |
voice:disconnect:user | client → server | Moderation |
voice:kicked, voice:device:disconnect, voice:room:leave | server → client | |
voice:state:restored | server → client | After a reconnect |
voice:error, voice:room:error | server → client |
Members, profile and presence
| Event | Direction |
|---|---|
members:fetch | client → server |
members:list, members:error | server → client |
profile:update | client → server |
profile:updated, profile:error | server → client |
avatar:updated | client → server |
presence:heartbeat | client → server |
presence:stale | server → 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.
| Event | Direction |
|---|---|
push:register, push:unregister | client → server |
push:presence | client → server |
Moderation
| Event | Direction |
|---|---|
server:kick, server:ban, server:unban | client → server |
server:mute, server:deafen | client → server |
server:bans:list | client → server |
server:kicked, server:muted, server:deafened | server → client |
server:kick:success, server:ban:success, server:unban:success, server:mute:success, server:deafen:success | server → client |
server:bans | server → client |
reports:list, reports:resolve | client → server |
reports:list, reports:resolved, report:submitted, report:already_reported | server → 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.
| Event | Direction | Notes |
|---|---|---|
server:settings:get, server:settings:update | client → server | The database-backed settings |
server:settings | server → client | |
server:channels:list, :upsert, :delete, :reorder | client → server | |
server:channels | server → client | |
server:channels:scope:get, server:channels:scope:set | client → server | Needs manage_channels. Who can use a channel: Everyone, a template or its own rules |
server:channels:scope:follow | client → server | Needs manage_channels. Drops the channel's own rules and goes back to following its folder |
server:channels:scope | server → client | Answer 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:set | client → server | Needs manage_channels. Same, for a folder — applies to every channel inside it that has no rules of its own |
server:folders:scope | server → client | Answer to both above. Carries channelCount, how many channels are following the folder |
server:permissions:templates:list, server:permissions:template:save, server:permissions:template:delete | client → server | Named sets of channel permissions that several channels can share |
server:permissions:templates | server → client | |
server:sidebar:list, :item:upsert, :item:delete, :reorder | client → server | |
server:sidebar | server → client | Leaves 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:set | client → server | |
server:roles, server:role:updated | server → client | |
server:invites:create, :list, :revoke | client → server | |
server:invites, server:invite:created, server:invite:revoked | server → client | |
server:member:invite | both | |
server:joinRequests:list, server:joinRequests:decide | client → server | |
server:joinRequests, server:joinRequest:decided | server → client | |
server:audit:list | client → server | |
server:audit | server → client | |
server:user:replace | client → server | |
server:user:replace:success | server → client | |
server:leave | client → server | |
server:left | server → client | |
server:details | both | |
server:clients | server → client | |
server:emojiQueue:get | client → server | |
server:emojiQueue:state, server:emojis:updated | server → client | |
server:version:check | client → server | |
server:version:status | server → client |
Diagnostics
| Event | Direction |
|---|---|
diagnostics:ping | client → server |
diagnostics:pong | server → client |