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. |
All four are live: changing token reconnects with the new identity,
which is how you move a reader to a writer after they sign in.
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.
| 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. |
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
The widget carries no design opinion beyond its layout: colours, spacing, radius
and typography come from you. 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 | #0f1216 | Background of the whole widget. |
surface | #161b22 | Composer, header, raised parts. |
border | #232b36 | Separators. |
text | #e6edf3 | Message text. |
text-muted | #8b98a5 | Timestamps, hints, counters. |
accent | #3ea6ff | Send button, nicknames, focus. |
accent-text | #04121f | Text on the accent colour. |
own-bg | #1b2a3a | Background of your own messages. |
system-text | #f0b849 | Service messages. |
moderator | #4ac776 | Moderator nicknames. |
danger | #f0616d | Refusals and destructive actions. |
radius | 8px | Corner radius. |
gap | 6px | Space between messages. |
padding | 10px | Inner padding. |
font-family | system stack | Typography. |
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. 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. |
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} |
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. |
presence | online, approximate and updated periodically. |
room_state | The rules changed: slow mode, status, appearance. |
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.
8. 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. |
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. |
| 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.
9. 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. |
| 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. |