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.
| Part | Used for | Where it lives |
|---|---|---|
id | Identifies the tenant inside a token, as the app claim. | Anywhere. |
secret | Signs participant tokens. | Your backend only. |
apiKey | Creates rooms, publishes service messages, mints tokens. | Your backend only. |
origins | The pages allowed to embed the chat. | Held by the deployment. |
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>
| Attribute | Meaning |
|---|---|
room | Room identifier. The element connects as soon as it is set. |
token | Participant token. Without it the element joins as a guest, which can read when the room allows it and cannot write. |
api | Base address of the deployment. Defaults to the origin of the page, which is what you want only when you serve the widget yourself. |
theme | JSON object of appearance overrides, applied on top of the theme stored with the room. See appearance. |
lang | Language 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}`;
| Claim | Required | Notes |
|---|---|---|
sub | yes | Your user identifier. Rate limits, mutes and bans are counted against it. |
app | yes | Tenant id. The deployment picks the signing secret by it. |
iss | yes | Always matchat. |
exp | yes | Keep it short. The widget reconnects, so a token that outlives a session is a token that can be reused. |
nick | no | Falls back to user- and the first six characters of sub. |
avatar | no | Image address, shown when the theme enables avatars. |
role | no | user by default. moderator and admin may moderate and are exempt from the per participant rate limit. system is refused and becomes user. |
room | no | When set, the token works in that room only; every other room answers 403. |
guest | no | Read 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.
closed keeps the conversation readable and refuses new messages.
Remove a room when the conversation itself should be gone.
| Setting | Default | What it does |
|---|---|---|
slowModeSec | 0 | Minimum seconds between messages from one participant. |
maxMessageLength | 300 | Counted in characters, not bytes. |
allowGuestRead | true | Lets a visitor without a token read the room. |
allowLinks | false | When off, anything that looks like an address is refused. |
allowReactions | true | Reactions on messages. |
allowReplies | true | Quoting another message. |
allowTranslation | true | Offers the translation control on messages. Has no effect unless the deployment has a translation provider; see translation. |
autoTranslate | false | Translates 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. |
historyLimit | 200 | Messages kept and handed to somebody who joins. |
bannedWords | [] | Case insensitive substrings that are refused. |
maxOnline | 50000 | Participants at once; further joins are refused with room_full. Capped by your tenant quota. |
roomRateLimit | 3000 | Messages 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.
| Key | Default | Applies to |
|---|---|---|
bg | #020307 | Background of the whole widget. Set to transparent to float it over your own background, which only works for the web component, not the iframe. |
ambient | yellow glow at the top | Light behind the conversation. Any background image, or none. |
surface | rgba(4,5,9,.48) | Fill of the header, the composer and the raised parts. Keep it translucent for the frosted effect. |
panel | white 4% fading down | Highlight painted over those surfaces. |
blur | 12px | How far the surfaces blur what is behind them. 0 makes every panel flat. |
ring | rgba(240,240,240,.12) | Hairline along the top edge of bubbles, chips and the action row. |
border | rgba(240,240,240,.08) | Separators and the input outline. |
text | #f0f0f0 | Message text. |
text-muted | #9e9e9e | Timestamps, hints, counters, quotes. |
nick | rgba(248,255,19,.85) | Nicknames. |
accent | #f8ff13 | Send button, focused input, own reactions. |
accent-text | #020307 | Text on a solid accent fill. |
accent-soft | rgba(248,255,19,.08) | Translucent accent fill of the send button and picked reactions. |
glow | rgba(248,255,19,.25) | Light inside accented controls. |
own-bg | rgba(248,255,19,.06) | Background of your own messages. |
system-text | #ff9b05 | Service messages. |
system-bg | rgba(255,155,5,.06) | Background behind them. |
moderator | #5ba71d | Moderator nicknames. |
danger | #ff3131 | Refusals, the live badge, destructive actions. |
radius | 12px | Corner radius of bubbles, the input and the send button. |
pill | 100px | Corner radius of the badge, reactions and the action row. |
gap | 8px | Space between messages. |
padding | 14px | Inner padding. |
font-family | "Euclid Circular A", then the system stack | Typography. 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-size | 14px | Base size. |
avatar | none | Set to block to show avatars. |
timestamp | none | Set 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.
| Call | Body | Effect |
|---|---|---|
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.
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
| Call | Answers |
|---|---|
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=0 | History, newest last, plus the room and the count online. beforeSeq pages backwards. |
POST /v1/rooms/{id}/messages | Sends 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}/stats | Online 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 send | Shape |
|---|---|
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 receive | Carries |
|---|---|
hello | you, room, history, online. |
batch | events: several of the below in one frame. Unwrap it before dispatching. |
message | One message, with reply already quoted and reactions counted. |
deleted | messageId that a moderator removed. |
reactions | New counts for one or more messages. |
translations | Automatic translations of messages delivered a moment earlier, in the language this connection asked for. Only in a room with autoTranslate; see translation. |
presence | online, approximate and updated periodically. |
room_state | The rules changed: slow mode, status, appearance. |
room_removed | The 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. |
pinned | A message was pinned, or noPin when it was removed. |
moderation | banned, muted or released, with userId and until. |
ack | Your message was accepted: clientMessageId, messageId, seq. |
error | code from the table below, text, and until when waiting helps. |
resync | Refetch the history: the node lost its place in the stream. Rare, and the honest alternative to pretending nothing happened. |
pong | Answer to your ping. |
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.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No token, or it does not verify. |
forbidden | 403 | A guest tried to write, or the token belongs to another room. |
room_closed | 409 | The topic is not accepting messages. |
slow_mode | 429 | Too soon after the previous message. Retry-After says when. |
rate_limited | 429 | The participant is sending faster than allowed. |
overloaded | 429 | The room as a whole is over its rate. |
duplicate | 400 | The same text again within thirty seconds. |
too_long | 400 | Longer than the room allows. |
empty | 400 | Nothing but whitespace. |
links_not_allowed | 400 | The room refuses addresses. |
blocked_words | 400 | Matched the word list of the room. |
banned / muted | 403 | Restricted by a moderator. |
room_full | 503 | The room is at its participant ceiling. |
unsupported_reaction | 400 | Not one of the six reactions. |
bad_language | 400 | The to of a translation request is not a language code. |
translation_off | 501 / 409 | 501: the deployment has no translation provider, which also refuses switching autoTranslate on. 409: the room has allowTranslation off. |
too_many_languages | 400 | More than five languages in autoTranslateLangs. |
translate_limited | 429 | A spending cap for translation, per reader or per room. Cached translations are still served. |
not_found | 404 | No such room or message. |
What the limits actually are
| Limit | Value |
|---|---|
| Messages from one participant | Five at once, then one every two seconds. |
| Reactions from one participant | Fifteen at once, then three every two seconds. |
| Translations paid for | On 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 room | Five, in autoTranslateLangs. The cost of a room is multiplied by their number. |
| Repeated text | Refused within thirty seconds. |
| Whole room | roomRateLimit, three thousand a second by default. |
| Frame from a client | Four kilobytes. |
| Request body | Sixty four kilobytes. |
Moderators and administrators are exempt from the per participant limits.
10. Constraints
| Rule | Detail |
|---|---|
| Room identifiers | May not begin with __: that prefix belongs to the internal channels of the deployment. |
| Reactions | A fixed set of six: ๐ ๐ฅ ๐ ๐ฎ ๐ข ๐. Anything else is refused, so counters stay bounded in a room of fifty thousand. |
| Message text | Trimmed, control characters removed, runs of whitespace collapsed. It is delivered as text and never as markup. |
| Emoji | Ordinary 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 replies | The 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. |
| Timestamps | Milliseconds since the epoch, everywhere. |
| Ordering | seq is per room and increases. Use it to place a message, not the timestamp. |
| Presence | online is approximate by design: an exact count across nodes would cost more than it is worth. |
| Removal is final | There 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. |