---
source_url: https://www.pubnub.com/docs/use-cases/sports-media-entertainment/game-chat-moderation
title: Hide and remove chat messages during a match
updated_at: 2026-09-30T07:20:08.000Z
---

# Hide and remove chat messages during a match

## Documentation index

To discover more PubNub resources:

1. Fetch [PubNub's llms.txt](https://www.pubnub.com/llms-full.txt) for a list of available pages in Markdown format.
2. Identify relevant URLs from that index.
3. Fetch the target pages.

Do not assume a path exists, always check the index first.

This tutorial builds message moderation for match-day chat with [Access Manager](https://www.pubnub.com/docs/security/access-control/overview.md) and [Message Persistence](https://www.pubnub.com/docs/data-storage/message-history/overview.md) on the PubNub JavaScript SDK. By the end, a moderator client publishes a hide decision to a control channel that only a moderator's token can write to, so every connected client hides the flagged message live. A client that joins later still renders it as hidden, because the same control channel's history carries the decision. The message is then deleted from Message Persistence for good. Deleting from history is permanent and needs your keyset's secret key, so that step runs on your own server, never in a client.

A fan's client can publish chat messages and reactions on `game.chat`. If hiding a message also ran through `game.chat`, whatever mechanism a fan uses to react could equally be used to fabricate a hide decision, since Access Manager grants permission on a channel, not on the specific type or value a client chooses to put in a message. This tutorial gives the hide decision its own channel instead, one that only a moderator's token can write to, so the permission fans get on `game.chat` never extends to hiding anyone's message.

## What you'll build

`server.js` holds the secret key and grants `moderator-7` a token with `write` on `game.chat.moderation`. `moderator.js` uses that token to publish a hide decision, naming the flagged message's timetoken, to `game.chat.moderation`. Every client subscribed to that channel receives the decision and hides the message live. A fan who joins later fetches history from both `game.chat` and `game.chat.moderation` and hides it too. Finally, `server.js` deletes the message from Message Persistence for good.

```mermaid
sequenceDiagram
    participant Server as server.js
    participant Mod as moderator.js
    participant Control as game.chat.moderation
    participant Fans as Fan clients
    participant History as Message Persistence

    Server-->>Mod: token with write on game.chat.moderation
    Mod->>Control: publish hide decision with message timetoken
    Control->>Fans: hide decision, message hidden live
    Fans->>History: late joiner fetches game.chat and game.chat.moderation
    Server->>History: delete the message permanently
```

## Before you begin

You need:

1. Node.js 22 or later.
2. A PubNub account and a keyset. A keyset is the set of publish, subscribe, and secret keys that identifies your application to the PubNub network. Follow [Set up your account](https://www.pubnub.com/docs/architecture/authentication/set-up-your-account.md) if you don't have one yet.
3. [Access Manager enabled](https://www.pubnub.com/docs/security/access-control/configure-access-control.md#enable-access-manager) on the keyset. Without it, any client holding your keys can already publish anywhere, and there's no way to keep the hide decision moderator-only.
4. Message Persistence enabled on the keyset, since flagging and deleting a message both depend on it being stored. Turn it on in the **MESSAGE PERSISTENCE** section of your keyset settings in the [Admin Portal](https://admin.pubnub.com/).
5. **Enable Delete-From-History** turned on in the same section. [Delete messages from history](https://www.pubnub.com/docs/data-storage/message-history/delete-messages-from-history.md) returns an error if this toggle is off.

You also need the publish key, subscribe key, and secret key from that keyset.

## Set up the project

Create a directory and install the SDK:

```bash
mkdir chat-moderation
cd chat-moderation
npm init -y
npm install pubnub
```

Add `"type": "module"` to `package.json`. Create two files: `moderator.js`, the moderator's own client, and `server.js`, which runs on your own infrastructure and holds the keyset's secret key. Only `server.js` ever sees the secret key.

`moderator.js`:

import PubNub from 'pubnub';
const pubnub = new PubNub({  publishKey: 'YOUR_PUBLISH_KEY',  subscribeKey: 'YOUR_SUBSCRIBE_KEY',  userId: 'moderator-7',});

`server.js`:

import PubNub from 'pubnub';
const server = new PubNub({  publishKey: 'YOUR_PUBLISH_KEY',  subscribeKey: 'YOUR_SUBSCRIBE_KEY',  secretKey: 'YOUR_SECRET_KEY',  userId: 'moderation-service',});

Replace `YOUR_PUBLISH_KEY`, `YOUR_SUBSCRIBE_KEY`, and `YOUR_SECRET_KEY` with the values from your keyset.

The secret key grants privileged access to your PubNub application. It must remain on a trusted server and must never be included in client applications.

## Give hide decisions their own channel

`game.chat.moderation` carries only one thing: a hide decision naming the timetoken of a message to hide. Nothing publishes chat content there, and no fan's token grants write access to it. `moderator.js` is the only client in this tutorial authorized to publish on it, and it proves that by presenting a token `server.js` grants it, bound to `moderator-7` specifically, the same `authorizedUserId` binding [Mute and ban a disruptive fan](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/fan-behavior-management.md#let-the-fan-into-the-chat) uses to restrict a fan's own token. A fan's own token grants `read` and `write` on `game.chat`, so a fan can publish a chat message or a reaction there, but that grant says nothing about `game.chat.moderation`. Without a grant naming that channel, a request to publish there is rejected before it reaches any client, regardless of what the fan puts in the message body. That is what makes a hide decision trustworthy: not the shape of the message, but the channel it had to be authorized to reach.

Everyone subscribed to `game.chat.moderation` can still read every hide decision on it, live and from history, the same as any other channel. Only writing to it is restricted.

|  | Hide (control channel) | Delete (Message Persistence) |
| --- | --- | --- |
| Reaches connected clients | Immediately | Not directly. A client that already has the message keeps it. |
| Reversible | Yes, publish a new decision | No |
| Message stays in history | Yes | No |
| Needs a moderator-scoped grant | Yes | No, needs the secret key instead |

```mermaid
flowchart TB
    MOD["<b>Moderator client</b><br/>reviews a message<br/>that is already delivered"]
    CHAT["<b>game.chat</b>"]
    DEC{"<b>Hide it now or<br/>remove it for good?</b>"}
    FLAG["<b>game.chat.moderation</b><br/>write requires a<br/>moderator-scoped token"]
    C1["<b>Fan client</b><br/>flag appears live"]
    C2["<b>Fan client</b><br/>flag appears live"]
    DEL["<b>Message Persistence</b><br/>message removed from<br/>history · permanent"]
    GONE["<b>Nothing is delivered</b><br/>clients that already have<br/>the message still show it"]

    MOD --> CHAT --> DEC
    DEC -->|"flag"| FLAG
    FLAG --> C1 & C2
    DEC -->|"delete"| DEL
    DEL -.-> GONE

    class CHAT emphasis
    class DEC,FLAG,DEL,GONE muted
```

The moderator reviews a message that `game.chat` has already delivered, then chooses between two paths. A hide decision on the control channel reaches every connected client, and each one acts on it live. A deletion from Message Persistence removes the message from history but sends nothing to clients, so a client that already has the message keeps showing it.

Flag a message first, so every current fan stops seeing it right away. Delete it only once you've decided the message shouldn't exist in your records at all, since that step can't be undone.

## Grant the moderator's token

Issue `moderator.js` a token that can publish to the control channel.

```javascript
try {
  const token = await server.grantToken({
    ttl: 60,
    authorizedUserId: 'moderator-7',
    resources: {
      channels: {
        'game.chat': { read: true },
        'game.chat.moderation': { read: true, write: true },
      },
    },
  });
  console.log('token that lets moderator-7 publish hide decisions:', token);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Granting moderator access failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Run this from `server.js` and copy the token it prints. `authorizedUserId: 'moderator-7'` means only a client authenticating as `moderator-7` can use it. The token carries `write` on `game.chat.moderation`. A fan's own token, granted elsewhere with `read` and `write` scoped to `game.chat` only, carries no such permission, so a fan's client cannot publish a hide decision no matter what it sends.

Apply the printed token in `moderator.js`:

```javascript
pubnub.setToken('replace-with-the-token-server-js-printed');
```

## Flag a message as hidden

Publish a hide decision naming the message you want hidden.

```javascript
try {
  const response = await pubnub.publish({
    channel: 'game.chat.moderation',
    message: {
      action: 'hide',
      messageTimetoken: 'replace-with-message-timetoken',
    },
    customMessageType: 'moderation-hide',
    storeInHistory: true,
  });
  console.log('message flagged at timetoken:', response.timetoken);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Flagging the message failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Append this call to `moderator.js`, replacing `replace-with-message-timetoken` with the timetoken of the message you want hidden, for example one your terminal printed while running [Build match-day chat for fans](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-chat.md). PubNub rejects this publish outright if `moderator.js` hasn't applied a token granting `write` on `game.chat.moderation`, before your own code ever runs. Keep the message's own timetoken. A later step needs it again.

## Hide the message on every client

Register the handler that turns a hide decision into a hidden message in your UI.

```javascript
const hiddenTimetokens = new Set();

const moderationSubscription = pubnub.channel('game.chat.moderation').subscription();

moderationSubscription.onMessage = (event) => {
  const decision = event.message;
  const action =
    typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'action' in decision
      ? decision.action
      : undefined;
  const messageTimetoken =
    typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'messageTimetoken' in decision
      ? decision.messageTimetoken
      : undefined;

  if (action === 'hide' && typeof messageTimetoken === 'string') {
    hiddenTimetokens.add(messageTimetoken);
    console.log('hide the message published at', messageTimetoken);
  }
};

moderationSubscription.subscribe();
```

Add this to `moderator.js` as a second subscription, this time on `game.chat.moderation`. In production, every fan's client runs this same handler and keeps its own set of hidden timetokens, checking it whenever it renders a message from `game.chat`. That's what makes the message disappear from their view the moment a moderator flags it, with no separate broadcast of your own.

## Keep it hidden for fans who load history

Fetch both channels together, so a fan who joins after you flagged a message still renders it as hidden.

```javascript
try {
  const response = await pubnub.fetchMessages({
    channels: ['game.chat', 'game.chat.moderation'],
    count: 25,
  });

  const moderationEntries = response.channels['game.chat.moderation'] ?? [];

  moderationEntries.forEach((entry) => {
    const decision = entry.message;
    const action =
      typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'action' in decision
        ? decision.action
        : undefined;
    const messageTimetoken =
      typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'messageTimetoken' in decision
        ? decision.messageTimetoken
        : undefined;

    if (action === 'hide' && typeof messageTimetoken === 'string') {
      hiddenTimetokens.add(messageTimetoken);
    }
  });

  const chatEntries = response.channels['game.chat'] ?? [];

  chatEntries.forEach((entry) => {
    const hidden = hiddenTimetokens.has(entry.timetoken.toString());
    console.log(entry.timetoken, hidden ? '[hidden by a moderator]' : entry.message);
  });
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Loading the moderated history failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Call this from `moderator.js` to see the shape a late-joining fan's client gets back. This fetches `game.chat` and `game.chat.moderation` in the same call, so a fan's client can build the same hidden-timetoken set from history that [Hide the message on every client](#hide-the-message-on-every-client) builds live, then apply it while rendering `game.chat`'s messages. A client that only fetched `game.chat` would never see the hide decisions at all, since those live on the other channel.

## Delete the message for good

Delete the flagged message from Message Persistence permanently, from `server.js`.

```javascript
try {
  const messageTimetoken = 'replace-with-message-timetoken';
  const start = (BigInt(messageTimetoken) - BigInt(1)).toString();
  const end = messageTimetoken;

  const response = await server.deleteMessages({
    channel: 'game.chat',
    start,
    end,
  });
  console.log('message deleted from Message Persistence:', response);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Deleting the message failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Replace the example `messageTimetoken` with the timetoken of the message you flagged, and keep it as a string. A PubNub timetoken exceeds the largest integer a JavaScript `Number` represents exactly, so increment or compare timetokens with an arbitrary-precision integer type such as `BigInt` rather than with plain numeric arithmetic. That's why the code computes `start` as the timetoken minus 1 with `BigInt` and passes the original string as `end`. The call deletes only the flagged message. For how `start` and `end` select messages, refer to [Delete messages from history](https://www.pubnub.com/docs/data-storage/message-history/delete-messages-from-history.md).

Run it only from `server.js`, since the secret key it needs must never reach a client. There's no undo. Once this call succeeds, the message is gone from every future history fetch, on every client.

## Let a policy do the routine cases

Flagging and deleting one message at a time doesn't scale to the volume of obvious violations a busy match produces. [Auto Moderation](https://www.pubnub.com/docs/message-processing/moderation/overview.md) runs before a message publishes, so a policy can block or report the routine cases automatically, leaving moderators to make the judgment calls this tutorial's manual flag handles. Set one up in [Configure Auto Moderation](https://www.pubnub.com/docs/message-processing/moderation/configure-auto-moderation.md).

:::note Beta feature
Auto Moderation is in **beta** and available upon request. Contact [PubNub Support](mailto:support@pubnub.com) or [Sales](https://www.pubnub.com/company/contact-sales/).
:::

## Run it

Open two terminals in `chat-moderation`. In the first, run:

```bash
node server.js
```

`server.js` grants the moderator token and logs `token that lets moderator-7 publish hide decisions: ...`. It then tries to delete `replace-with-message-timetoken`, which isn't a real timetoken yet, so that call logs an error. That's expected on this first run.

Copy the printed token into `moderator.js`, replacing `replace-with-the-token-server-js-printed`. Then, using a real message timetoken from [Build match-day chat for fans](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-chat.md), replace `replace-with-message-timetoken` in `moderator.js`. In the second terminal, run:

```bash
node moderator.js
```

`moderator.js` flags the message, logs `message flagged at timetoken: ...`, immediately logs the same hide event a fan's client would receive, then logs the history fetch showing `game.chat`'s message rendered as hidden. Now replace `replace-with-message-timetoken` in `server.js` with the same real timetoken, and run `node server.js` again:

```bash
node server.js
```

This time the delete call succeeds and logs `message deleted from Message Persistence: ...`. Press **Ctrl+C** in either terminal to stop it.

## What happened

1. `server.js` granted `moderator-7` a token with `write` on `game.chat.moderation`, the only permission that lets a client publish a hide decision.
2. `moderator.js` applied that token and published a hide decision naming the flagged message's timetoken. A fan's own token, scoped only to `game.chat`, could not have made that same call.
3. Every client subscribed to `game.chat.moderation`, moderator and fan alike, received that decision live and hid the referenced message.
4. A history fetch across both channels returned the same decision, so a fan who joins later renders the message as hidden too.
5. `server.js`, holding the keyset's secret key, deleted the message from Message Persistence. From that point on, no fetch of `game.chat`'s history returns it.

## Next steps

* [Build match-day chat for fans](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-chat.md). Build the chat this tutorial moderates.
* [Mute and ban a disruptive fan](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/fan-behavior-management.md). Stop a fan from posting at all, instead of hiding one message at a time.
* [Access Manager](https://www.pubnub.com/docs/security/access-control/overview.md). The token model behind the moderator-only grant this tutorial issues.
* [Moderation](https://www.pubnub.com/docs/message-processing/moderation/overview.md). Where Auto Moderation and Channel Monitor fit next to the manual flag this tutorial built.

Last updated at: 2026-09-30T07:20:08.000Z
