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.

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}`;
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.

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.
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

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.

KeyDefaultApplies to
bg#0f1216Background of the whole widget.
surface#161b22Composer, header, raised parts.
border#232b36Separators.
text#e6edf3Message text.
text-muted#8b98a5Timestamps, hints, counters.
accent#3ea6ffSend button, nicknames, focus.
accent-text#04121fText on the accent colour.
own-bg#1b2a3aBackground of your own messages.
system-text#f0b849Service messages.
moderator#4ac776Moderator nicknames.
danger#f0616dRefusals and destructive actions.
radius8pxCorner radius.
gap6pxSpace between messages.
padding10pxInner padding.
font-familysystem stackTypography.
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}/bansCurrent 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

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.
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}
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.
presenceonline, approximate and updated periodically.
room_stateThe rules changed: slow mode, status, appearance.
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.

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.

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.
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.
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.

9. 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.
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.