MatChat — integration reference

everything below is what this deployment actually enforces

1. Your tenant

A tenant is issued by the operator of the deployment and is what separates your rooms, your participants and your quota from everyone else's. It has four parts.

PartUsed forWhere it lives
idIdentifies the tenant inside a token, as the app claim.Anywhere.
secretSigns participant tokens.Your backend only.
apiKeyCreates rooms, publishes service messages, mints tokens.Your backend only.
originsThe pages allowed to embed the chat.Held by the deployment.
The origins list is enforced, not advisory. A websocket handshake from a page that is not on the list is answered with 403, and a cross origin request gets no CORS header. Send the operator every host that will embed the chat, including staging. This deployment currently answers on https://matchat.space.

Neither the secret nor the API key may reach a browser. Both hand out room creation and moderator tokens to whoever reads the page.

2. Embedding the widget

The widget is a custom element. Load the script once and place the element wherever the chat belongs; it fills the height of its container, so give that container one.

<script src="https://matchat.space/widget/matchat.js"></script>

<div style="height: 620px">
  <mat-chat
    room="match-12345"
    token="<participant token from your backend>"
    api="https://matchat.space"></mat-chat>
</div>
AttributeMeaning
roomRoom identifier. The element connects as soon as it is set.
tokenParticipant token. Without it the element joins as a guest, which can read when the room allows it and cannot write.
apiBase address of the deployment. Defaults to the origin of the page, which is what you want only when you serve the widget yourself.
themeJSON object of appearance overrides, applied on top of the theme stored with the room. See appearance.
langLanguage of the reader, as ru or pt-BR. It is what a message is translated into — both when somebody asks for a translation and when the room translates on its own. Leave it out and the browser's own language is used. Not part of the token: a reader changes language without signing in again.

All of them are live: changing token reconnects with the new identity, which is how you move a reader to a writer after they sign in, while changing lang keeps the connection and only affects what is translated next.

An iframe instead

When the page cannot load third party script, embed the widget page itself. It accepts the same values as query parameters.

<iframe src="https://matchat.space/widget/index.html?room=match-12345&token=..."
        style="width:100%;height:620px;border:0"></iframe>

3. Participant tokens

Reading can be open to guests; writing always needs a token. A token says who the participant is and what they may do, and your backend is the only thing that decides that — the chat never authenticates anybody itself.

In production: sign it yourself

The token is a JWT signed with your tenant secret using HS256. No call to the chat is involved, so minting one costs your backend nothing.

import { createHmac } from 'node:crypto';

const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const now = Math.floor(Date.now() / 1000);

const head = b64({ alg: 'HS256', typ: 'JWT' });
const body = b64({
  sub:  'u-42',            // your user id, required
  nick: 'Alice',           // shown in the room
  role: 'user',            // user | moderator | admin
  app:  'demo',            // your tenant id
  room: 'match-12345',     // optional: locks the token to one room
  iat:  now,
  exp:  now + 7200,
  iss:  'matchat',
});

const signed = `${head}.${body}`;
const sig = createHmac('sha256', TENANT_SECRET).update(signed).digest('base64url');
const token = `${signed}.${sig}`;
ClaimRequiredNotes
subyesYour user identifier. Rate limits, mutes and bans are counted against it.
appyesTenant id. The deployment picks the signing secret by it.
issyesAlways matchat.
expyesKeep it short. The widget reconnects, so a token that outlives a session is a token that can be reused.
nicknoFalls back to user- and the first six characters of sub.
avatarnoImage address, shown when the theme enables avatars.
rolenouser by default. moderator and admin may moderate and are exempt from the per participant rate limit. system is refused and becomes user.
roomnoWhen set, the token works in that room only; every other room answers 403.
guestnoRead only participant with an identity.

While prototyping: ask the deployment

Convenient for a stand and never for production, because it needs the API key of the tenant.

POST /v1/admin/tokens
X-API-Key: <apiKey>
Content-Type: application/json

{ "userId": "u-42", "nick": "Alice", "role": "user",
  "roomId": "match-12345", "ttlSec": 7200 }

200 โ†’ { "token": "eyJhbGciOi...", "expiresAt": 1788600000000 }

ttlSec defaults to two hours. expiresAt is in milliseconds, like every timestamp in this interface.

4. Rooms and their rules

A room is a topic, not a conversation between two people: everyone in it sees the same messages. Create it before the first participant arrives, from your backend.

POST /v1/admin/rooms
X-API-Key: <apiKey>
Content-Type: application/json

{ "id": "match-12345",
  "title": "Team A vs Team B",
  "topic": "Premier League, round 4",
  "status": "live",
  "settings": { "slowModeSec": 0, "maxMessageLength": 300 } }

201 โ†’ the full room

topic is the only required field. Omitting id derives one from the topic. Sending the same id again updates the room rather than failing, which makes the call safe to repeat when a match page is opened twice.

GET /v1/admin/rooms answers with every room of your tenant and the rules in force, which is what a dashboard of your own needs.

Removing a room

DELETE /v1/admin/rooms/match-12345
X-API-Key: <apiKey>

204 โ†’ gone, along with its history, reactions and bans

Everyone reading the room is told and disconnected, on every node, so nobody is left holding a socket to a topic that no longer exists. The identifier is free again afterwards and comes back empty, with the ordering counter starting over.

A finished match is usually closed, not removed: closed keeps the conversation readable and refuses new messages. Remove a room when the conversation itself should be gone.
SettingDefaultWhat it does
slowModeSec0Minimum seconds between messages from one participant.
maxMessageLength300Counted in characters, not bytes.
allowGuestReadtrueLets a visitor without a token read the room.
allowLinksfalseWhen off, anything that looks like an address is refused.
allowReactionstrueReactions on messages.
allowRepliestrueQuoting another message.
allowTranslationtrueOffers the translation control on messages. Has no effect unless the deployment has a translation provider; see translation.
autoTranslatefalseTranslates every message as it arrives instead of waiting to be asked. Off by default because it is the one setting here that spends money on its own; see translation.
autoTranslateLangs[]The languages it translates into, as base codes such as ["en","es"]. At most five: the cost of a room is multiplied by their number.
historyLimit200Messages kept and handed to somebody who joins.
bannedWords[]Case insensitive substrings that are refused.
maxOnline50000Participants at once; further joins are refused with room_full. Capped by your tenant quota.
roomRateLimit3000Messages per second for the whole room, across all participants.

A partial settings object is merged over the rules in force, so a call that changes one field leaves the rest alone.

Status

scheduled, live, paused, closed. Only live accepts messages; the others keep the room readable. Change it with PATCH /v1/admin/rooms/{id}.

Service messages

Events of your own product belong in the room as service messages: a goal, half time, a delay. They are not written by a participant and are not rate limited.

POST /v1/admin/rooms/match-12345/system
X-API-Key: <apiKey>

{ "text": "Goal! Team A scores. 1 : 0", "kind": "goal" }

201 โ†’ the message, as participants receive it

5. Appearance

Out of the box the widget is dark and frosted: translucent panels over a near black background, a hairline catching light along their top edge and a single accent colour. Every part of that is a key you can replace — colours, spacing, radius, blur and typography. Store a theme with the room and every participant gets it, or pass the theme attribute to override it on one page.

PUT /v1/admin/rooms/match-12345/theme
X-API-Key: <apiKey>

{ "bg": "#ffffff", "surface": "#f4f6f8", "text": "#0b1020",
  "accent": "#1a7f37", "radius": "12px", "font-family": "Inter, sans-serif" }

Each key becomes a CSS custom property named --mc-<key> inside the widget, so anything the browser accepts for that property is accepted here. Unknown keys are ignored.

KeyDefaultApplies to
bg#020307Background of the whole widget. Set to transparent to float it over your own background, which only works for the web component, not the iframe.
ambientyellow glow at the topLight behind the conversation. Any background image, or none.
surfacergba(4,5,9,.48)Fill of the header, the composer and the raised parts. Keep it translucent for the frosted effect.
panelwhite 4% fading downHighlight painted over those surfaces.
blur12pxHow far the surfaces blur what is behind them. 0 makes every panel flat.
ringrgba(240,240,240,.12)Hairline along the top edge of bubbles, chips and the action row.
borderrgba(240,240,240,.08)Separators and the input outline.
text#f0f0f0Message text.
text-muted#9e9e9eTimestamps, hints, counters, quotes.
nickrgba(248,255,19,.85)Nicknames.
accent#f8ff13Send button, focused input, own reactions.
accent-text#020307Text on a solid accent fill.
accent-softrgba(248,255,19,.08)Translucent accent fill of the send button and picked reactions.
glowrgba(248,255,19,.25)Light inside accented controls.
own-bgrgba(248,255,19,.06)Background of your own messages.
system-text#ff9b05Service messages.
system-bgrgba(255,155,5,.06)Background behind them.
moderator#5ba71dModerator nicknames.
danger#ff3131Refusals, the live badge, destructive actions.
radius12pxCorner radius of bubbles, the input and the send button.
pill100pxCorner radius of the badge, reactions and the action row.
gap8pxSpace between messages.
padding14pxInner padding.
font-family"Euclid Circular A", then the system stackTypography. The widget loads no font: a face declared inside it would be ignored by the browser, so the page has to carry the one it names.
font-size14pxBase size.
avatarnoneSet to block to show avatars.
timestampnoneSet to inline to show times.

The theme editor writes exactly this call and shows the result live.

6. Moderation

Moderation is done with a participant token whose role is moderator or admin — not with the API key. That is deliberate: a moderator acts from a browser, and the key must never be there.

CallBodyEffect
DELETE /v1/rooms/{id}/messages/{messageId}—Removes the message for everyone, including from history.
POST /v1/rooms/{id}/restrict{"userId":"u-9","seconds":300}Mutes for that long. Without seconds it is a ban with no end.
POST /v1/rooms/{id}/release{"userId":"u-9"}Lifts a mute or a ban.
GET /v1/rooms/{id}/bans—Current restrictions, with until in milliseconds and -1 for permanent.
POST /v1/rooms/{id}/pin{"messageId":"..."}Pins a message; an empty id unpins.
POST /v1/rooms/{id}/mode{"status":"paused","slowModeSec":10}Pauses the topic or sets slow mode, up to 300 seconds. Only live and paused are allowed here.

Every one of these reaches all participants immediately, on every node: a deleted message disappears from open pages, and a muted participant is told for how long.

7. Translation

A message can be shown in the language of whoever is reading it. In the widget that is the globe on a message: it puts the translation under the original and the original stays, one press away. The language is the lang attribute of the element, or the language of the browser when you leave it out.

POST /v1/rooms/match-12345/messages/mtoo9f1c8ab2/translation
Authorization: Bearer <participant token>
{"to": "ru"}

200 → {
  "messageId": "mtoo9f1c8ab2",
  "to": "ru",
  "text": "\u043a\u0430\u043a\u043e\u0439 \u0433\u043e\u043b \u0441 \u0443\u0433\u043b\u0430",
  "sourceLang": "en",
  "translated": true,
  "cached": true
}

A reader who may read the room may translate it, so a guest of a room with allowGuestRead needs no token here. to is a language code such as ru, de or pt-BR; anything else is refused as bad_language rather than guessed at.

translated: false means there was nothing to translate — a row of emoji, a scoreline — and text is then the original. Say so rather than showing the same words twice.

What makes any of this affordable is that the answer is cached by the text and not by the message: the first reader pays, every reader after them is served free, and the same chant sent by ten thousand people is one translation. cached tells you which of the two happened.

The whole room at once

A room can also translate as it goes, with nobody pressing anything. Turn it on in the rules and name the languages of your audience:

PATCH /v1/admin/rooms/match-12345
X-API-Key: <your api key>
{"settings": {"autoTranslate": true, "autoTranslateLangs": ["en", "es"]}}

Languages are base codes without a region — en, not en-GB — and at most five of them: the cost of a room is multiplied by their number. A code that is not one is refused as bad_language, and switching this on where the deployment has no provider is refused as translation_off, rather than leaving you with a room that looks configured and does nothing.

Each connection is then sent the one language it asked for, in a translations frame that arrives a few seconds after the messages it belongs to — the messages of a moment are collected and translated together, and a provider takes a second or three over a batch. The language comes from the handshake:

wss://matchat.space/v1/ws?room=match-12345&token=<token>&lang=en

← {"type":"translations","translations":[
     {"lang":"en","items":[
       {"messageId":"mtoo9f1c8ab2","text":"the ref is blind","source":"ru"}
     ]}
   ]}

Translations are a separate frame rather than part of the message, because they cannot be part of it: the message is delivered the instant it is accepted and the translation comes back from a provider seconds later. Put it under the original and keep the original: a machine translation is a reading aid, and what was actually said has to stay available, to the reader and to a moderator. The widget does this on its own; nothing to build.

A reader who changes language sends {"type":"lang","lang":"es"} and keeps their connection. There is no need to rejoin, and rejoining would refetch the history and lose their place in it. A language the room does not translate into simply matches nothing.

Messages with nothing to translate never reach a provider, repeated text is paid for once per window, and a room sending faster than the provider answers drops the translations it cannot keep up with rather than falling behind. The chat itself is never held up for a translation.

Languages do not queue behind each other: each is asked for separately and they are asked for at once, so a room in five languages is translated as quickly as a room in one. It is why five is the limit — the delay stays put as languages are added, and only the bill grows.

What it costs

Translation is the one thing here billed per use, and the arithmetic decides the design. On request, the spending follows what is actually read, which is why the control exists at all. Automatically, it follows what is written, multiplied by the number of languages: a busy match room is a few dollars an hour per language, a quiet one is cents. Both are bounded by the caps below, so neither can surprise the operator.

What it costs you to switch on

Nothing in your integration: the provider and its key belong to the deployment, not to you. If translation_off comes back with status 501, this deployment has no provider configured — ask the operator. With status 409 it is the room: allowTranslation is off.

Spending caps protect the bill, counted across the whole deployment and only for translations that actually reach the provider: on request, thirty a minute per reader and six hundred a minute per room; automatically, three thousand a minute per room counted once per message per language. Over a cap a requested translation answers translate_limited with status 429 — treat it as an unavailable button, not an error worth showing. Automatically there is nothing to answer: what does not fit is quietly dropped, a language at a time, so a room near its cap keeps the languages it can still afford and stays readable in the one it is written in. Anything already paid for keeps being served.

8. Your own interface

The widget is one client of a protocol that is open to you. Build your own and you still get history, presence, moderation and the same rules.

Reading over HTTP

CallAnswers
GET /v1/rooms/{id}Title, topic, status, the rules a client needs, the theme, the pinned message.
GET /v1/rooms/{id}/messages?limit=100&beforeSeq=0History, newest last, plus the room and the count online. beforeSeq pages backwards.
POST /v1/rooms/{id}/messagesSends one message with a participant token.
POST /v1/rooms/{id}/messages/{messageId}/reactions{"emoji":"๐Ÿ”ฅ","on":true} sets or withdraws a reaction.
POST /v1/rooms/{id}/messages/{messageId}/translation{"to":"ru"} answers with the message in that language. See translation.
GET /v1/rooms/{id}/statsOnline count, message rate, and which node answered.

The stream

const ws = new WebSocket(
  'wss://matchat.space/v1/ws?room=match-12345&token=' + token
);

The first frame is hello: who you are, the room, the recent history and the count online. After that the stream carries what happens.

You sendShape
send{"type":"send","body":"...","replyTo":"<messageId>","clientMessageId":"c-17"}
react{"type":"react","messageId":"...","emoji":"๐Ÿ”ฅ","on":true}
lang{"type":"lang","lang":"es"} — changes which automatic translations this connection is sent, without rejoining.
ping{"type":"ping"}

clientMessageId is worth setting: a retry after a lost connection carries the same one and is not delivered twice. The answer comes back as ack with the identifier the message actually got.

You receiveCarries
helloyou, room, history, online.
batchevents: several of the below in one frame. Unwrap it before dispatching.
messageOne message, with reply already quoted and reactions counted.
deletedmessageId that a moderator removed.
reactionsNew counts for one or more messages.
translationsAutomatic translations of messages delivered a moment earlier, in the language this connection asked for. Only in a room with autoTranslate; see translation.
presenceonline, approximate and updated periodically.
room_stateThe rules changed: slow mode, status, appearance.
room_removedThe room named by roomId no longer exists. The connection closes right after, and reconnecting is pointless: the handshake can only refuse. Say so and stop.
pinnedA message was pinned, or noPin when it was removed.
moderationbanned, muted or released, with userId and until.
ackYour message was accepted: clientMessageId, messageId, seq.
errorcode from the table below, text, and until when waiting helps.
resyncRefetch the history: the node lost its place in the stream. Rare, and the honest alternative to pretending nothing happened.
pongAnswer to your ping.
Frames are batched inside a short window, so a busy room does not turn into one frame per message. A client that assumes one event per frame will silently miss messages: handle batch first.

The API only client is a working example of exactly this, in about a hundred lines.

9. Refusals and limits

A refusal is never silent. Over HTTP it is a status with {"error":"<code>"}, over the stream an error event with the same code, and the codes are stable enough to switch on.

CodeStatusMeaning
unauthorized401No token, or it does not verify.
forbidden403A guest tried to write, or the token belongs to another room.
room_closed409The topic is not accepting messages.
slow_mode429Too soon after the previous message. Retry-After says when.
rate_limited429The participant is sending faster than allowed.
overloaded429The room as a whole is over its rate.
duplicate400The same text again within thirty seconds.
too_long400Longer than the room allows.
empty400Nothing but whitespace.
links_not_allowed400The room refuses addresses.
blocked_words400Matched the word list of the room.
banned / muted403Restricted by a moderator.
room_full503The room is at its participant ceiling.
unsupported_reaction400Not one of the six reactions.
bad_language400The to of a translation request is not a language code.
translation_off501 / 409501: the deployment has no translation provider, which also refuses switching autoTranslate on. 409: the room has allowTranslation off.
too_many_languages400More than five languages in autoTranslateLangs.
translate_limited429A spending cap for translation, per reader or per room. Cached translations are still served.
not_found404No such room or message.

What the limits actually are

LimitValue
Messages from one participantFive at once, then one every two seconds.
Reactions from one participantFifteen at once, then three every two seconds.
Translations paid forOn request: thirty a minute per reader, six hundred a minute per room. Automatically: three thousand a minute per room, counted once per message per language. Counted across the deployment, and only when the provider is actually called.
Languages of one roomFive, in autoTranslateLangs. The cost of a room is multiplied by their number.
Repeated textRefused within thirty seconds.
Whole roomroomRateLimit, three thousand a second by default.
Frame from a clientFour kilobytes.
Request bodySixty four kilobytes.

Moderators and administrators are exempt from the per participant limits.

Opening many connections from one address is limited too. This deployment accepts ten handshakes a second per address, with a burst of twenty, and refuses the rest. One person never notices; a test that opens a thousand connections from one machine sees half of them fail and should ramp up slowly or ask the operator for an exemption.

10. Constraints

RuleDetail
Room identifiersMay not begin with __: that prefix belongs to the internal channels of the deployment.
ReactionsA fixed set of six: ๐Ÿ‘ ๐Ÿ”ฅ ๐Ÿ˜‚ ๐Ÿ˜ฎ ๐Ÿ˜ข ๐Ÿ’”. Anything else is refused, so counters stay bounded in a room of fifty thousand.
Message textTrimmed, control characters removed, runs of whitespace collapsed. It is delivered as text and never as markup.
EmojiOrdinary text, and they survive intact, including the composed ones. maxMessageLength counts characters the way Unicode does, so ๐Ÿ˜€ is one and ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is seven. If you build your own input, count the same way: a browser's value.length counts ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ as eleven and would cut a message short.
Quoted repliesThe quote is taken when the message is sent and truncated to a hundred and twenty characters, so a client never has to look the original up.
TimestampsMilliseconds since the epoch, everywhere.
Orderingseq is per room and increases. Use it to place a message, not the timestamp.
Presenceonline is approximate by design: an exact count across nodes would cost more than it is worth.
Removal is finalThere is no recovering a removed room: the history, the reactions and the moderator decisions go with it. A conversation that should merely stop growing is bounded by historyLimit on its own, and a topic that should stop accepting messages is closed.