# Member Counter Documentation

Member Counter is a Discord bot which lets you display counts and other dynamic information in various places, such as voice channel name or text channel topic.

Member Counter rewrites a channel name or a channel topic every few minutes. You write a **template**: ordinary text with one or more **counters** in it.

A counter can be the member count, the number of people online, roles, Nitro boosters, a clock in another timezone, a countdown, a Twitch follower count, a Reddit subscriber count, or the result of your own HTTP request. You can nest counters, do arithmetic on them and format the digits.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-858f69311a58c2cb0c392bea4cf30edf3323ee96%2Fdiscord-counter-channels-example.png?alt=media" alt="A Discord channel list showing a Server Stats category with counter channels"><figcaption><p>Counters running in a Discord server</p></figcaption></figure>

[Add Member Counter to your server](https://membercounter.app/invite) to try it, or join [our support server](https://membercounter.app/support) if these pages do not answer your question.

## Two ways to set up counters

{% tabs %}
{% tab title="With a slash command" %}
Run `/setup server` in Discord. The bot creates a `📊 Server Stats 📊` category with five ready-made counter channels. Nothing else to configure.

[Quick setup](/getting-started/quick-setup)
{% endtab %}

{% tab title="In the dashboard" %}
Open the [dashboard](https://membercounter.app/dashboard), pick your server and click a channel. Mix text, emoji and any counter, preview the result and save it.

[Creating your first counter](/getting-started/create-from-scratch)
{% endtab %}
{% endtabs %}

`/setup` writes ordinary templates, so you can open anything it created in the dashboard and change it.

## The different bots

**Member Counter** is the free public bot. It updates channels every 10 minutes and cannot run the Twitch and YouTube counters or the finer Members filters. **Member Counter Premium** is the legacy premium bot, reserved for servers that hold a legacy server upgrade. It updates every 5 minutes and adds the Twitch and YouTube counters, but it has no privileged intents either.

For 5 minute updates and the full counter list without a legacy upgrade, run the bot under your own token, either [hosted by us as a custom bot](/custom-bot/custom-bot) or [on your own machine](/self-hosting/self-hosting).

Every counter page states which bots it works on, and [How counters update](/using-the-dashboard/how-counters-update) explains the refresh timing.

## Where to go next

* [Adding the bot to your server](/getting-started/adding-bot-to-the-server)
* [Dashboard overview](/using-the-dashboard/using-the-bot)
* [Counters reference](/counters-reference/counters-list)
* [Commands](/commands/bot-commands)
* [How to contribute](/contributing/how-to-contribute)


# Adding the bot to your server

Invite Member Counter to your Discord server and understand every permission it asks for.

You need the **Manage Server** permission in the Discord server you are adding the bot to. If you do not have it, ask an administrator to follow these steps.

{% stepper %}
{% step %}
**Open the invite page**

Use the [direct invite link](https://membercounter.app/invite), or [log in to the dashboard](https://membercounter.app/dashboard), pick a server in the list on the left and press **Add to \<server name>**.

Without **Manage Server** the dashboard offers **Copy invite link** instead. Copy it and send it to an administrator.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-dd8e084ad6e8b4847323150f9befd7be1d6c0f1e%2Fdashboard-add-to-server.png?alt=media" alt="The dashboard invite page with the Add to server button"><figcaption><p>The dashboard offers to add the bot to the selected server</p></figcaption></figure>
{% endstep %}

{% step %}
**Choose the server**

Discord asks which server the bot should join. Only servers where you have **Manage Server** appear in the list. Pick yours and press **Continue**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-9c101d10c9210dc88a84a53b13e6b0d99db2228e%2Fdiscord-invite-server-picker.png?alt=media" alt="Discord&#x27;s authorization dialog with the server dropdown open"><figcaption><p>Discord asks where to add the bot</p></figcaption></figure>
{% endstep %}

{% step %}
**Review the permissions and authorize**

Discord lists the permissions the bot asks for. Uncheck the ones the table below marks as not required, then press **Authorize**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5a551ce760a6404c12ce0b64377b467323fa8595%2Fdiscord-invite-permissions.png?alt=media" alt="Discord&#x27;s permission list for the Member Counter invite"><figcaption><p>The eight permissions the invite link requests</p></figcaption></figure>
{% endstep %}
{% endstepper %}

## Permissions the bot requests

| Permission               | Required                           | Why the bot asks for it                                                                                                                                                           |
| ------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **View Channels**        | Yes                                | The bot can only edit a channel it can see. A channel it cannot see is skipped.                                                                                                   |
| **Manage Channels**      | Yes                                | This is what renames a channel or rewrites its topic. Every counter needs it.                                                                                                     |
| **Connect**              | Yes, for voice channels            | Without it the bot cannot act on a voice channel. Text, announcement and category counters do not need it.                                                                        |
| **Send Messages**        | No                                 | Not used by any counter today.                                                                                                                                                    |
| **Read Message History** | No                                 | Not used by any counter today.                                                                                                                                                    |
| **Embed Links**          | No                                 | Not used by any counter today.                                                                                                                                                    |
| **Manage Roles**         | Yes, if you run `/setup`           | [`/setup`](/commands/setup) creates its channels together with permission overwrites, and Discord refuses that unless the bot has **Manage Roles**. Without it the command fails. |
| **Ban Members**          | Yes, for the banned members option | The bot reads the server's ban list for the banned-members option of the [Members counter](/counters-reference/members).                                                          |

One failing counter fails the whole template, so if you uncheck **Ban Members** and later write a template that counts banned members, that channel is renamed to **ERROR!**

{% hint style="info" %}
**Manage Roles** is only used by `/setup` when it creates its channels with permission overwrites. The bot never creates, edits, assigns or deletes roles.
{% endhint %}

## Per-channel permissions

Authorizing grants those permissions server-wide, but a single channel can override them. If the bot loses **View Channel**, **Manage Channel** or **Connect** in one channel, that channel stops updating and nothing is posted in Discord to tell you. The dashboard is where you find out: open the channel and a warning tells you the bot cannot edit it, with a **Check again** button.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-6781eee0901fc6175a20f34067bf8abdd06d952b%2Fsettings-channel-missing-permissions.png?alt=media" alt="The missing permissions warning"><figcaption><p>The permissions the bot needs on this specific channel</p></figcaption></figure>

Next: [Quick setup](/getting-started/quick-setup) or [Creating your first counter](/getting-started/create-from-scratch). See [Troubleshooting](/using-the-dashboard/troubleshooting) for the other failure modes.

<details>

<summary>Technical details</summary>

* The dashboard only offers to add the bot when your Discord permissions in that server include Administrator or Manage Guild. Outside a secure context the copy button is replaced by the plain link.
* The dashboard runs the same `botHasPermsToEdit` check as the bot (`packages/common/src/botHasPermsToEdit.ts`), and **Check again** refetches it.
* When the check fails during an update, the bot logs "The bot does not have sufficient permissions to edit the channel." against that channel and skips it. Nothing is sent to Discord, so the dashboard is the only place the error surfaces.
* `/setup` needs Manage Roles because it creates every channel with two overwrites: the bot member allowed Connect and View Channel, the `@everyone` role denied Connect.
* Gateway intents are not part of the invite and cannot be granted here. They are enabled in the Discord Developer Portal and computed from the instance flags.
* Self-hosting: `/invite` builds its link from `MAIN_DISCORD_CLIENT_ID`, which `.env.example` sets to the official bot's id `478567255198662656`, so a self-hosted bot invites the official bot until you change it. The command's second button uses `DISCORD_CLIENT_ID` (your own instance) and only appears when you have Manage Guild in the server where you ran it.

</details>


# Quick setup

Create a whole set of working counter channels with one command, then refine them in the dashboard.

One `/setup` command and the bot builds a category full of counter channels that already work. It is the first thing to do after [adding the bot to your server](/getting-started/adding-bot-to-the-server).

You need the **Administrator** permission, and you have to run it inside a server: `/setup` is not available in DMs.

## Run the command

Type `/setup` in any channel and Discord offers three subcommands:

| Command                         | What it does                     |
| ------------------------------- | -------------------------------- |
| `/setup server`                 | Set up server-related counters.  |
| `/setup twitch username:<name>` | Set up Twitch-related counters.  |
| `/setup youtube url:<link>`     | Set up YouTube-related counters. |

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-ce98d4c6f4247d811e824860944b61e8acc215bc%2Fcommand-setup-picker.png?alt=media" alt="Discord&#x27;s command picker showing the three setup subcommands"><figcaption><p>Discord offers the three subcommands as you type</p></figcaption></figure>

The reply is ephemeral: only you can see it. It edits itself as the bot works, one line per channel, until every channel has been created or has failed.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-a155e32d3bad8385ba3cd5dd08e64968507b5193%2Fcommand-setup-server-reply.png?alt=media" alt="The ephemeral reply of /setup server after it finished"><figcaption><p>The reply mid-run, and after Creation completed.</p></figcaption></figure>

## What each subcommand creates

`/setup server` creates a category named `📊 Server Stats 📊` with five voice channels, in this order:

| Channel         | Counter behind it                                                  |
| --------------- | ------------------------------------------------------------------ |
| `Online: <n>`   | [Members](/counters-reference/members), filtered to online members |
| `Members: <n>`  | [Members](/counters-reference/members)                             |
| `Boosters: <n>` | [Nitro Boosters](/counters-reference/nitro-boosters)               |
| `Roles: <n>`    | [Roles](/counters-reference/roles)                                 |
| `Channels: <n>` | [Channels](/counters-reference/channels)                           |

On the public bot and on the legacy premium bot, `Online:` counts online, idle and do-not-disturb members together, because neither has the presence intent. On a [custom bot](/custom-bot/custom-bot) and a [self-hosted bot](/self-hosting/self-hosting) it is strictly online.

`/setup twitch` creates `📊 <twitch channel name> Twitch Stats 📊` with `<n> Followers` and `Viewers: <n>`. When the channel is not live the viewers counter shows the word `offline` instead of a number.

`/setup youtube` creates `📊 <youtube channel name> YouTube Stats 📊` with `<n> Subscribers`, `<n> Videos` and `<n> Views`.

{% hint style="warning" %}
Both of them fail on the free public bot, which cannot run the [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube) counters. Nothing is created and the bot replies with an error. They work on the legacy premium bot, on a custom bot and on a self-hosted bot with the Twitch and YouTube credentials configured.
{% endhint %}

## Why the new channels have a lock

`/setup` locks the channels it creates, so members read them like a label but cannot join them and sit inside a counter. Administrators can still join. This is also why the invite link asks for [**Manage Roles**](/getting-started/adding-bot-to-the-server#permissions-the-bot-requests).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-c2377716fa652b867ee8aa6b91f1bf6f6315defb%2Fdiscord-setup-server-result.png?alt=media" alt="The Server Stats category created by /setup server"><figcaption><p>The result in the channel list</p></figcaption></figure>

## The numbers are not moving yet

The channels keep the value they had when they were created and pick up the next one on the normal cycle, see [How counters update](/using-the-dashboard/how-counters-update).

## Continue in the dashboard

`/setup` writes ordinary templates, so everything it created is yours to edit. Open the [dashboard](https://membercounter.app/dashboard), pick your server and click one of the new channels. The **Template** box already holds the template `/setup` wrote.

More in the [Template editor](/using-the-dashboard/template-editor), [Creating your first counter](/getting-started/create-from-scratch) and the [`/setup` command reference](/commands/setup).

<details>

<summary>Technical details</summary>

* Discord itself hides the command from members without Administrator and from DMs, so those calls never reach the bot. What each subcommand creates is defined in `apps/bot/src/interactions/commands/setup.ts`.
* The reply is deferred as ephemeral before the handler runs, and `/setup` edits that same reply after each channel instead of sending a new message.
* The category name is itself a template and is created first; the channels are then created in parallel. If the category fails, every channel line is marked as failed and the error is rethrown, which replaces the reply with the error embed and a **Join support server** button.
* On the public bot the Twitch and YouTube categories fail with "The bot is not a premium version and lacks certain features needed for this counter.", which is why no channel is created either.
* Each channel is created as a voice channel inside the category with two permission overwrites: the bot member allowed Connect and View Channel, the `@everyone` role denied Connect. The template is then stored with `isTemplateEnabled: true` and a `LastTemplateUpdateDate` log entry.
* Channel names are evaluated once at creation, with the same call the update job makes, so a name that is already too long is truncated at 99 characters there and then.
* Commands are deployed globally by default, or to a single server when `DISCORD_BOT_INSTANCE_DEPLOY_COMMANDS` holds a guild id. Names, descriptions and option names are localized at deploy time, so `/setup` and its options appear translated for users on another Discord language.
* Self-hosting: `/setup twitch` needs `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`, `/setup youtube` needs `YOUTUBE_API_KEY`, and the instance must run with `DISCORD_BOT_INSTANCE_IS_PREMIUM=true`.

</details>


# Creating your first counter

Build a counter channel yourself in the dashboard, from an empty channel to a live template.

[Quick setup](/getting-started/quick-setup) gives you a set of ready-made channels. Here you pick the channel yourself and write the template.

{% hint style="info" %}
**Before you start,** [**invite the bot**](/getting-started/adding-bot-to-the-server) **and open** [**the dashboard**](https://membercounter.app/dashboard)**.** You need **Administrator** or **Manage Server** in the server.
{% endhint %}

{% stepper %}
{% step %}
**Create a channel in Discord**

Make a voice channel, a category, a text channel or an announcement channel. Voice channels and categories get their **name** rewritten, text and announcement channels their **topic**. The name you give it now does not matter; the bot overwrites it on the next refresh.

{% hint style="warning" %}
Forum, media and stage channels are greyed out because they are not supported yet.
{% endhint %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-597d5aa51b8b7d8eb65b8aacb3b6686cf7e20dbf%2Fdiscord-create-channel.png?alt=media" alt="Discord&#x27;s Create Channel dialog"><figcaption><p>Any voice, category, text or announcement channel will do</p></figcaption></figure>
{% endstep %}

{% step %}
**Open the channel in the dashboard**

Select your server in the rail on the left, then click the channel you just made. If the bot is missing a permission there, the page says so. Fix it in Discord and press **Check again**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-2f9f62da1199c27390cdc1d8a22d2f5ec4412b61%2Fdashboard-channel-page.png?alt=media" alt="The dashboard channel page with the template editor"><figcaption><p>The channel page: the enable toggle, the template editor and the save button</p></figcaption></figure>
{% endstep %}

{% step %}
**Turn on the Enable template toggle**

Nothing happens until this toggle is on.
{% endstep %}

{% step %}
**Write the text and add a counter**

Type the fixed part of the name first, for example `Members:` , then press **Add counter** and pick the counter you want. It is inserted as a chip, and the text around it stays exactly as you typed it, emoji included.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-37b356da1260f7006ccb63f5762545db715983e4%2Feditor-add-counter.png?alt=media" alt="The Add counter picker open over the template editor"><figcaption><p>Search the counter list from inside the editor</p></figcaption></figure>
{% endstep %}

{% step %}
**Configure the counter**

Click the chip to open its options panel. Every counter has its own options; the [Counters reference](/counters-reference/counters-list) has a page for each one. **Format settings**, at the bottom of the panel, is the same for every counter: leave it alone and the counter follows your [Server settings](/using-the-dashboard/server-settings).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8974771752cd590779388c736da3407c55a4d2a1%2Feditor-counter-options.png?alt=media" alt="The counter options panel open beside the template editor"><figcaption><p>Each counter brings its own options</p></figcaption></figure>

**Edit display label**, in the panel header, renames the chip in the editor only; the channel output does not change.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5254b9d12a785a9c152d88154b99e810a591f7b2%2Feditor-display-label.png?alt=media" alt="The Display label dialog beside the counter options panel"><figcaption><p>A display label only renames the chip in the editor</p></figcaption></figure>
{% endstep %}

{% step %}
**Preview, then save**

Press **Preview** on the editor toolbar to swap the chips for the values they return right now, and **Edit** to go back. When it looks right, press **Save**. Autosave is off by default and is switched on from your [account settings](/using-the-dashboard/account).

{% hint style="warning" %}
A template for a channel **name** has to come out at least 2 characters long after trimming. A shorter result fails with **The result for the channel name is less than 2 characters long.** and the bot renames the channel to **ERROR!**
{% endhint %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-ffb8ef56db57c5ea0226187dcd23c7e875aa3d42%2Feditor-preview-first-counter.png?alt=media" alt="The template editor in preview mode"><figcaption><p>Preview resolves every counter to its current value</p></figcaption></figure>
{% endstep %}
{% endstepper %}

## When will the channel change?

Not immediately. Counters refresh every 10 minutes on the free public bot and every 5 minutes on the legacy premium bot, a custom bot and a self-hosted bot, and the schedule does not run at the moment you save. Your channel picks up its new name on the next refresh, see [How counters update](/using-the-dashboard/how-counters-update).

Next: the [Template editor](/using-the-dashboard/template-editor), [Nested counters](/using-the-dashboard/nested-counters) and [Formatting counters](/using-the-dashboard/customizing-counters).

<details>

<summary>Technical details</summary>

* `isBotSupportedChannel` decides which channel types can carry a template.
* Editing a server needs Administrator or Manage Guild in that server, or a staff account with the ManageGuilds permission. Without it the page answers "You don't have permission to manage this server.\nAsk an admin at {{guildName}} to give you Administrator or Manage Guild permissions."
* **Preview** runs the same `evaluateTemplate` on the bot as a real update, with your channel's real type, so it hits the same truncation, timeouts and errors as the bot would.
* `evaluateTemplate` trims the result, then cuts a text or announcement **topic** at 1023 characters and any other supported channel **name** at 99. Each counter gets 60 seconds ("The evaluation took too long and timed out."), and the nested-counter walk aborts after 50,000 iterations. These limits live in `packages/services/src/DataSource/index.ts`.
* Format settings resolve per counter first and fall back to the server's locale, compact notation and digits. Custom digits are only substituted on text and announcement channels, never in a channel name.
* Autosave fires 10 seconds after the last edit (`AUTOSAVE_DEBOUNCE_MS = 10_000`) and every further edit re-arms it; clicking the button during the countdown saves at once. Leaving with unsaved changes asks for confirmation first.
* The channel row stores `template` and `isTemplateEnabled`. The update job reads only enabled templates, so turning the toggle off leaves the last written name or topic in place instead of restoring the old one.
* After a failed update the channel page shows the stored reason, and the channel is marked in the server nav menu.
* Self-hosting: the website reaches the bot process over Redis pub/sub with `BDE_CALL_TIMEOUT` as the request timeout (2000 ms in `.env.example`), so **Preview** fails when no bot process answers in time.

</details>


# Dashboard overview

Find your way around the dashboard: the server rail, the channel list, and where every setting lives.

You build and manage every counter on the dashboard. The bot itself has only a handful of commands.

## Opening the dashboard

Go to <https://membercounter.app/dashboard> and log in with Discord. The dashboard opens the first server you are in.

To manage a server you need **Administrator** or **Manage Guild** in that Discord server.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8754f68c5275fe167fccbe0fd9ec17af857577f7%2Fdashboard-overview.png?alt=media" alt="The dashboard with a server open"><figcaption><p>The server rail on the far left, the channel list next to it, and the server page on the right</p></figcaption></figure>

## The server rail

The server rail lists every Discord server you are in, one icon per server. Click an icon to switch servers.

On the hosted dashboard the rail also carries a **Create custom bot** button and the custom bots you own. Click a custom bot to open its own page instead of a server page. A [self-hosted bot](/self-hosting/self-hosting) has no custom bots, because the instance you run is your own bot.

## The server sidebar

The sidebar of the server you picked has the server name, a gear for **Server settings**, and the channel list in Discord's own order. Click a channel to open its page and edit its template.

Channel types the bot does not support yet are greyed out and have no channel page. See [Channel settings](/using-the-dashboard/channel-settings).

A channel with a template switched on carries a small dot:

| Dot          | What it means                                            |
| ------------ | -------------------------------------------------------- |
| Orange       | The template is on and no error is recorded.             |
| Red, pinging | The last run failed. Open the channel to read the error. |

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-16c1c43e7244d102c1494aec5dc6c18a209a6b35%2Fdashboard-channel-list-dots.png?alt=media" alt="Channel list with status dots"><figcaption><p>An orange dot means the counters are running; a red pinging dot means the last update failed</p></figcaption></figure>

## When you cannot open the server page

| What you see                                                                                                                                                                       | What it means                                                                                                                                                                                                 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Let's set up the bot!** with an **Add to \<server>** button                                                                                                                      | The bot is not in the server. See [Adding the bot to the server](/getting-started/adding-bot-to-the-server). Without permission to add bots you get **Copy invite link** instead: hand that link to an admin. |
| **Permission Denied**                                                                                                                                                              | You do not have Administrator or Manage Guild in that Discord server. Getting one of them is the only fix.                                                                                                    |
| A red banner naming the [Terms of Service](https://membercounter.app/legal/terms-of-service) or the [Acceptable Use Policy](https://membercounter.app/legal/acceptable-use-policy) | The server is blocked, and the banner shows the reason. Dismissing it does not lift the block. Contact us on the [support server](https://membercounter.app/support) if you think it is a mistake.            |

If the bot has settings saved for a server but is no longer in it, a banner offers to add it back. Your templates are still there and start working again as soon as you add the bot back.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8350cec735a97d4109851e9383a402ea8b62f315%2Fdashboard-invite-bot-page.png?alt=media" alt="Let&#x27;s set up the bot!"><figcaption><p>What you see when the bot is not in the server yet</p></figcaption></figure>

<details>

<summary>Technical details</summary>

* `/dashboard` redirects to `/dashboard/servers`, which picks the user's first guild and redirects to `/dashboard/servers/:guildId`; a channel page is `/dashboard/servers/:guildId/:channelId` and the gear goes to `/dashboard/servers/:guildId/settings`.
* `/dashboard/bots`, `/dashboard/bots/:botId` and `/dashboard/servers/:guildId/upgrade` exist only in the hosted build, so a self-hosted dashboard has no custom bot block in the rail.
* A user in no servers at all gets a page with links to create one and to join the support server.
* Reading, modifying and inviting all need Administrator or Manage Guild in that Discord server. Reading also accepts the internal `SeeGuilds` permission and modifying the internal `ManageGuilds` one; inviting the bot accepts neither.
* The dot reads `isTemplateEnabled` and the stored `LastTemplateComputeError`, nothing else, so a channel the bot has never processed also shows orange.
* The supported channel types are text, announcement, voice and category; everything else is greyed out (`packages/common/src/channelType.ts`).
* **Create custom bot** has a 15-second cooldown after each creation, and the button is blocked until it passes.
* When self-hosting, the website's outbound links come from `VITE_SUPPORT_URL`, `VITE_BOT_DOCS_URL` (default `https://docs.membercounter.app/`), `VITE_BOT_REPO_URL` and `VITE_TRANSLATION_PLATFORM_URL`.

</details>


# Template editor

Write a template with text, emojis, mentions, formatting and counters, then preview it and save it.

A template is what the bot writes into a channel. It is ordinary text with counters dropped into it, and you edit it in the template editor on any [channel page](/using-the-dashboard/channel-settings).

Type `Members:` , press **Add counter**, pick **Members**, and the channel shows `Members:` followed by the count.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-94e9ce5ee410e2d79b31f08a5af3326569b8d16e%2Feditor-toolbar.png?alt=media" alt="The template editor toolbar"><figcaption><p>Formatting marks and the emoji picker on the left, Preview and Add counter on the right</p></figcaption></figure>

## The toolbar

| Control                | What it does                                                                                                                                |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Mark buttons           | Toggle **bold**, **italic**, **underline**, **strike** and **spoiler** on the selected text. Only the marks the channel supports are shown. |
| Emoji button           | Opens the emoji picker.                                                                                                                     |
| **Preview** / **Edit** | Swaps between editing the template and seeing the evaluated result.                                                                         |
| **Add counter**        | Opens the counter list and inserts the one you pick at the cursor.                                                                          |

## Names and topics

Text and announcement channels get their channel topic set, so the editor offers the full Discord grammar. Voice channels and categories get their channel name set, and Discord channel names carry no formatting, so the editor hides it.

| Feature                                                      | Channel topic | Channel name                             |
| ------------------------------------------------------------ | ------------- | ---------------------------------------- |
| Plain text                                                   | Yes           | Yes                                      |
| Counters                                                     | Yes           | Yes                                      |
| **Bold**, **italic**, **underline**, **strike**, **spoiler** | Yes           | No, the buttons are not shown            |
| Role and channel mentions                                    | Yes           | No                                       |
| Unicode emoji                                                | Yes           | Yes, inserted as plain text              |
| Custom server emoji                                          | Yes           | No, the picker only offers Unicode emoji |

Open a channel and the editor is already in the right mode.

## Formatting marks

Select some text and press a mark button, or use the keyboard. On macOS use <kbd>Cmd</kbd> instead of <kbd>Ctrl</kbd>.

| Mark      | Shortcut                       | Saved as       |
| --------- | ------------------------------ | -------------- |
| Bold      | <kbd>Ctrl</kbd> + <kbd>B</kbd> | `**text**`     |
| Italic    | <kbd>Ctrl</kbd> + <kbd>I</kbd> | `*text*`       |
| Underline | <kbd>Ctrl</kbd> + <kbd>U</kbd> | `__text__`     |
| Strike    | <kbd>Ctrl</kbd> + <kbd>S</kbd> | `~~text~~`     |
| Spoiler   | <kbd>Ctrl</kbd> + <kbd>H</kbd> | `\|\|text\|\|` |

{% hint style="warning" %}
A `*`, `_`, `~` or `|` typed into plain, unformatted text is saved exactly as you typed it, so Discord may render the channel topic italic or spoilered when you did not ask for it.
{% endhint %}

## The emoji picker

The smiley button opens the emoji picker. It offers the custom emojis of every server that both you and the bot are in, not only the one you are editing.

On a channel name template the picker offers no custom emojis and inserts plain text, because Discord does not render custom emojis in channel names.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5a17dbb76be7d5cd0283ce07f589c984619a62a8%2Feditor-emoji-picker.png?alt=media" alt="The emoji picker"><figcaption><p>Custom server emojis sit above the Unicode groups</p></figcaption></figure>

## Mentions

In a channel topic template, type `@` followed by letters to search roles, or `#` followed by letters to search channels.

| Key                                | Action                       |
| ---------------------------------- | ---------------------------- |
| <kbd>↑</kbd> / <kbd>↓</kbd>        | Move through the suggestions |
| <kbd>Enter</kbd> or <kbd>Tab</kbd> | Insert the selected one      |
| <kbd>Esc</kbd>                     | Dismiss the list             |

## Adding a counter

**Add counter** opens a searchable list of every counter. Search also matches keywords, so `timezone` finds **Clock** and **Date**. Every counter has its own page under [Counters](/counters-reference/counters-list).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3c0c5249a6584a190f8de92add2170c472e6fb18%2Feditor-add-counter-list.png?alt=media" alt="The Add counter list"><figcaption><p>The counter list, searchable by name and by keyword</p></figcaption></figure>

## Counter chips

An inserted counter is not text. It is a chip with a generated name, such as `Members online with roles` or `Clock (Europe/Madrid)`. The name changes as you change the counter's options, so a chip always says what it will return.

* Click a chip, or select it and press <kbd>Enter</kbd> or <kbd>Space</kbd>, to open its edit panel.
* A chip obeys the marks around it, so a bold chip renders bold.
* Copying and pasting a chip makes an independent copy. Editing the copy does not touch the original.

## The edit panel

Clicking a chip opens its options, documented on each counter's page. The panel also carries a collapsible **Format settings** block at the bottom; see [Formatting counters](/using-the-dashboard/customizing-counters).

{% hint style="warning" %}
**Format settings** always applies to the outermost counter of the chip, see [Nested counters](/using-the-dashboard/nested-counters).
{% endhint %}

### Display label

The tag button in the panel header opens the **Display label** dialog. Type a name there and the chip shows it instead of the generated name. It never changes the channel output, and it helps when several chips would otherwise look the same. Clear the field to get the generated name back.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5254b9d12a785a9c152d88154b99e810a591f7b2%2Feditor-display-label.png?alt=media" alt="The Display label dialog"><figcaption><p>A display label renames the chip in the editor, never the output</p></figcaption></figure>

## Preview

**Preview** turns the editor read-only and renders the template as the bot would write it: every chip is replaced with its real value. The dashboard recomputes it about a second after you stop typing, so it lags your edits slightly.

A fatal error renders nothing and shows the error message instead. A non-fatal error still gives you a result, with one message per error below it. The most common is an [HTTP counter](/counters-reference/http) that failed and fell back to its fallback value.

Preview is also the only way to check a counter whose format or timezone comes from another counter, because those cannot show a live preview of their own.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-37ae3f5d62c5f401193a2756235539f2d8772ca4%2Feditor-preview.png?alt=media" alt="A template in Preview mode"><figcaption><p>Preview replaces every chip with its real value and lists any errors below</p></figcaption></figure>

## Saving

Press **Save** to store the template. Autosave is off by default and is switched on from [your account](/using-the-dashboard/account). The browser warns you if you leave the page with unsaved changes.

{% hint style="info" %}
Saving a template does not update the channel straight away; the bot rewrites channels on a schedule, see [How counters update](/using-the-dashboard/how-counters-update).
{% endhint %}

<details>

<summary>Technical details</summary>

* The editor is `apps/frontend/src/app/dashboard/servers/[guildId]/TemplateEditor/`: the grammar sets under `serde/` decide what a channel name (counters only) and a channel topic (marks, emoji, mentions and counters) accept, and `serde/serialize.ts` turns the document into the stored string.
* A saved template is one string: Discord markdown, `<:name:id>` or `<a:name:id>` emojis, `<@&id>` and `<#id>` mentions, and each counter serialised as JSON wrapped in U+001F. That is the invisible character you see in a template copied out of the database.
* A `*` typed as plain text reaches Discord unescaped: the serializer only escapes a delimiter inside text that already carries that mark.
* Preview calls the `bot.computeTemplate` tRPC query, debounced by 1 second. A fatal error is a thrown `KnownError`; a non-fatal one is a counter that used its fallback (`FallbackUsedError`).
* Autosave is a 10 second debounce over the per-user `prefersAutosave` preference.
* The counter list is ordered by the numeric `DataSourceId` enum value, not by name, and search matches each counter's `keywords` string from the locale file. Pasting a chip clones its counter with a fresh ref id.

</details>


# Channel settings

Enable a template on a channel, and what the bot writes to the channel name or the channel topic.

Every channel has its own page on the dashboard. Pick a channel in the sidebar and you get one switch, one template, and any warnings that apply.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-baf54787e27219bd473f67e97ba2c3a102e3ca69%2Fsettings-channel-page.png?alt=media" alt="A channel page"><figcaption><p>The Enable template switch, the template editor with its Last updated caption, and the Save button</p></figcaption></figure>

## Which channels the bot can update

| Channel type                 | The bot sets the |
| ---------------------------- | ---------------- |
| Text or announcement channel | channel topic    |
| Voice channel or category    | channel name     |

Any other type is greyed out because it is not supported yet.

Voice channels are the usual choice, because their name is always visible in the channel list. A text channel hides its topic behind the channel header.

## Enable template

The switch at the top decides whether the bot touches the channel at all. Turn it off and your template is kept, but the channel is left alone. The bot turns it off by itself when the channel is deleted.

## Template

You write the template in the [template editor](/using-the-dashboard/template-editor). **Last updated** is when the bot last processed this channel, not when you last saved, and it stays unknown until the bot processes it for the first time.

Editing changes nothing until you press **Save**, which saves the switch and the template together. Autosave does it for you if you turn it on from [your account](/using-the-dashboard/account).

| Limit                 | Value           | What happens when you cross it                                                                                                                                   |
| --------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Channel topic length  | 1023 characters | The result is cut to 1023 characters.                                                                                                                            |
| Channel name length   | 99 characters   | The result is cut to 99 characters.                                                                                                                              |
| Channel name minimum  | 2 characters    | The update fails with **The result for the channel name is less than 2 characters long.** Spaces are trimmed first, so pad a short name with a word or an emoji. |
| Time per counter chip | 60 seconds      | Counts every counter nested inside the chip. The update fails with an evaluation timeout.                                                                        |

Discord also throttles how often a channel may be renamed, and the bot only rewrites channels on a schedule, see [How counters update](/using-the-dashboard/how-counters-update).

{% hint style="warning" %}
Custom digits only apply to channel **topics**. Channel **names** get plain digits, whatever you set in [Server settings](/using-the-dashboard/server-settings) or in a counter's **Format settings**, because Discord does not render them there. Compact notation works everywhere. See [Formatting counters](/using-the-dashboard/customizing-counters).
{% endhint %}

## The missing permissions warning

If the bot cannot edit the channel, a warning on the channel page lists what is needed: **View Channel** and **Manage Channel**, plus **Connect** on a voice channel. Fix the permissions in Discord (usually a channel-level override) and press **Check again**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-6781eee0901fc6175a20f34067bf8abdd06d952b%2Fsettings-channel-missing-permissions.png?alt=media" alt="The missing permissions warning"><figcaption><p>The permissions the bot needs on this specific channel</p></figcaption></figure>

## The template error card

When the last run failed, the page shows the message from that run. If the bot hit something it does not recognise, the message is **An unknown error occurred.**

While a template is failing, the bot writes `ERROR!` into the channel name or topic on every run, so a channel renamed to `ERROR!` means the template is broken. The card reports the last scheduled update, so it can linger after you fix the cause. Use **Preview** to check your fix straight away, and see [Troubleshooting](/using-the-dashboard/troubleshooting) for the common messages.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e09b4fc3983c1b914a9b54ccbd16559170ebbcea%2Fdashboard-template-error.png?alt=media" alt="The template error card"><figcaption><p>What the last failed update reported</p></figcaption></figure>

<details>

<summary>Technical details</summary>

* The 60-second budget applies per delimited counter in the template and raises `EVALUATION_TIMEOUT`; nested counters spend the same budget. The nested-counter explorer also stops after 50,000 steps.
* The channel update job skips the Discord edit when the computed value equals the current name or topic, so **Last updated** moves on runs that changed nothing.
* On a failed evaluation the same job writes `jobs.updateChannels.computeError` into the name or topic. It is `ERROR!` in en-US, translated with the Discord server's `preferredLocale`, so it can reach the channel in another language.
* A non-fatal error is stored in the same place as a fatal one (the job saves `nonFatalErrors[0]`), so an HTTP counter that used its fallback still shows the error even though the channel was updated correctly.
* The dashboard permission check is the `bot.canBotEditChannel` query; the bot runs its own check for Manage Channels, View Channel, and Connect on a voice channel, logs `NO_ENOUGH_PERMISSIONS_TO_EDIT_CHANNEL` and skips the channel.
* A Discord `UnknownChannel` reply makes the job call `disableTemplate`, which is what turns the switch off for a deleted channel.
* The logs are two Redis keys per channel with no expiry: `cl:<channelId>:e` (last error) and `cl:<channelId>:t` (last update, a millisecond timestamp). A successful run deletes the error key.
* The card prints one of the 25 names in `packages/common/src/KnownError/DataSourceError.ts` (a channel name under 2 characters is `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`), or **An unknown error occurred.** for anything else.

</details>


# Formatting counters

Change how a single counter prints its number, with compact notation, a locale and custom digits.

Every number a counter returns is formatted with compact notation, a locale and custom digits. [Server settings](/using-the-dashboard/server-settings) sets them for the whole server; **Format settings** sets them for one counter.

Click a counter chip in the [template editor](/using-the-dashboard/template-editor) to open its edit panel, then expand **Format settings**. It always edits the counter that owns the chip, even while you are drilled into one of its [nested counters](/using-the-dashboard/nested-counters). A nested counter has no format settings of its own.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3b4dc1441ea064158cd8ec3f4a415f355994da9b%2Feditor-format-settings.png?alt=media" alt="Format settings expanded in the counter edit panel"><figcaption><p>The three controls inside <strong>Format settings</strong>.</p></figcaption></figure>

## Use compact notation

| Option                         | What it does                                                                                 |
| ------------------------------ | -------------------------------------------------------------------------------------------- |
| **Default to server settings** | Follow the server's **Use compact notation for numbers** switch. This is the starting value. |
| **Yes**                        | Always shorten the number for this counter.                                                  |
| **No**                         | Always print the number in full for this counter.                                            |

Compact notation shortens large numbers the way the locale does it. In `en-US`, `12300` becomes `12K` and `1500000` becomes `1.5M`. In `de-DE`, `1500000` becomes `1,5 Mio.` Pick **No** on a counter whose exact value matters, such as a Nitro booster count, while the rest of the server stays compact.

## Override default locale

Tick the checkbox and pick a locale for this counter only, or leave it clear to use the server's locale. The list holds the full ISO locale set, and you can also type a tag that is not listed and select what you typed. The locale decides the group separator and decimal mark of a number, the suffix compact notation uses (`K`, `mil`, `万`), and the words the [Clock](/counters-reference/clock) and [Date](/counters-reference/date) counters print, such as month and weekday names.

{% hint style="warning" %}
**Tick the checkbox only if you are going to pick a value.** Ticking it stores an empty locale right away, and the bot does not ignore an empty or malformed tag. A counter that returns a number and uses compact notation fails: the channel page shows *"An unknown error occurred."* and the bot writes the literal text `ERROR!` into the channel on the next update. A malformed tag such as `en_US` breaks Clock and Date too. To drop a locale of your own on this counter, untick the checkbox instead of clearing the combobox.
{% endhint %}

## Override default digits

Tick the checkbox and ten inputs appear, one per digit. Whatever you put in an input replaces that digit in this counter's output.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-12105b16215065bd8ff9adbcab18cb67956acbf9%2Feditor-format-custom-digits.png?alt=media" alt="The ten custom digit inputs"><figcaption><p>One input per digit.</p></figcaption></figure>

The inputs work like the ones in [Server settings](/using-the-dashboard/server-settings#custom-digits). Custom digits are hard for screen readers to read out, for the reasons given there.

## Which setting wins

Digits resolve one by one, so a counter can override three digits and take the other seven from the server. A digit that is empty in both places falls back to the plain digit for that position.

## Display label

**Display label** opens from the tag button in the edit panel. It is a custom name shown on this counter's chip in the editor, and it does not change the channel output. Use it to tell several similar chips apart, such as three [Roles](/counters-reference/roles) counters in one template. Clear the field to go back to the counter's normal chip text. Unlike **Format settings**, the display label follows whichever counter you are looking at, so a label set on a [nested counter](/using-the-dashboard/nested-counters) names it in the breadcrumb.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5254b9d12a785a9c152d88154b99e810a591f7b2%2Feditor-display-label.png?alt=media" alt="The Display label dialog"><figcaption><p>A display label renames the chip in the editor only.</p></figcaption></figure>

## What is never formatted

* **Counters that return text.** [Clock](/counters-reference/clock), [Date](/counters-reference/date), [Countdown](/counters-reference/countdown), [Concat](/counters-reference/concat) and [Replace](/counters-reference/replace) print exactly what they return. A locale still matters to Clock and Date, because they use it to build that text in the first place.
* **Channel names.** Custom digits only reach the **topic** of a text or announcement channel, because Discord channel names cannot show them. Compact notation still applies to a name. See [Channel settings](/using-the-dashboard/channel-settings).
* **Nested counters.** The bot formats the outermost result only. A [Math](/counters-reference/math) counter that adds two Members counters formats the sum once, not the two parts.

{% hint style="warning" %}
**Do not wrap a number in a counter that returns text.** A number counter inside a [Concat](/counters-reference/concat) or a [Replace](/counters-reference/replace) loses every setting on this page, because the outer result is already text. Write the literal text in the template around the counter instead: `Members:` and then the counter, not a Concat of the two.
{% endhint %}

## Examples

{% tabs %}
{% tab title="Exact numbers in one channel" %}
The server is compact by default, but the booster channel should show the real number. The server keeps **Use compact notation for numbers** on, and the [Nitro boosters](/counters-reference/nitro-boosters) counter sets **Use compact notation** to **No**. A server with `1250` boosts then shows `1.3K` everywhere and `1250` in that one channel.
{% endtab %}

{% tab title="A Japanese clock in an English server" %}
The server locale is `en-US`, but one channel should read as Japanese. The [Date](/counters-reference/date) counter ticks **Override default locale** and picks `ja-JP`. Everything else in the server keeps English month and weekday names.
{% endtab %}

{% tab title="Emoji digits on one counter" %}
The [Members](/counters-reference/members) counter sits in the **topic** of a text channel, with **Override default digits** ticked and each input filled with a custom server emoji. `438` prints as the emoji for `4`, `3` and `8`. Move the same counter to a voice channel name and it prints `438`.
{% endtab %}
{% endtabs %}

<details>

<summary>Technical details</summary>

* Resolution at evaluation time, per field: `locale = dataSource.format.locale ?? guild.formatSettings.locale`, same for `compactNotation`, and digit `i` = the counter's digit when non-empty, else the guild digit when non-empty, else the literal digit (`packages/services/src/DataSource/index.ts`).
* The root counter's prepared format settings are passed to every nested evaluator, so a nested Clock gets the locale, but only the outermost value goes through the formatter.
* Compact notation is `new Intl.NumberFormat(locale, { notation: "compact" })` with the locale as stored. An invalid tag throws a `RangeError` there; the job stores its raw message in `LastTemplateComputeError` and writes the string `ERROR!` into the channel. The channel page shows "An unknown error occurred." because any message that is not a `DataSourceErrorNames` value falls back to `UNKNOWN`.
* Evaluators get the locale replaced with `en-US` when it is shorter than two characters; the formatter does not, which is why an empty locale breaks number counters but not Clock or Date.
* Digit substitution runs only when `isTextLikeChannel(channelType)` is true, so voice and category names skip it.
* A number counter whose result is `NaN` raises `FAILED_TO_RETURN_A_FINAL_STRING`: "Failed to generate a final string from the evaluation."
* A digit input accepts the same marks and emojis as a channel topic, and it takes no nested counters.
* **Display label** is stored as `displayLabel` on the counter's JSON and is never read during evaluation.
* Nothing on this page depends on an environment variable when self-hosting.

</details>


# Server settings

Set the locale, compact notation and custom digits used by every counter in your server.

**Server settings** holds the defaults every counter in the server starts from: **Use compact notation for numbers**, **Locale** and **Custom digits**.

Open it from the dashboard: pick your server, then click the gear next to the server name. You need **Manage Server** or **Administrator** in the Discord server to open it.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3a556de8a50bdf30098a2bce22dbf34c39e13ddc%2Fsettings-server-overview.png?alt=media" alt="The Server settings page"><figcaption><p>The whole page: three settings and the buttons at the bottom.</p></figcaption></figure>

Any single counter can ignore these defaults. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Use compact notation for numbers

A switch, **on by default**. Turn it off and every counter prints its number in full, unless the counter overrides it.

The examples under it are written in the locale you picked, and they always show the compact form, whatever the switch is set to.

| Example   | With locale `en-US` | With locale `de-DE` |
| --------- | ------------------- | ------------------- |
| `12300`   | `12K`               | `12.300`            |
| `439212`  | `439K`              | `439.212`           |
| `1500000` | `1.5M`              | `1,5 Mio.`          |

## Locale

A searchable combobox. **The default is `en-US`.**

The locale decides the group separator and decimal mark of a number, the suffix compact notation uses, and the words the [Clock](/counters-reference/clock) and [Date](/counters-reference/date) counters print, such as month and weekday names. With `en-US`, `15:30h` shows as `3:30 PM` and `439212` as `439K`; with `de-DE`, the same two show as `15:30` and `439.212`.

The list holds the full ISO locale set. You can also type a locale that is not listed and select what you typed.

{% hint style="warning" %}
**Type a real locale tag, such as `en-US`.** Nothing checks the value you save: a bad one makes counters that use compact notation write the literal text `ERROR!` into the channel on the next update, and breaks Clock and Date.

To recover, set **Locale** back to a valid tag and save. An empty field is not the default.
{% endhint %}

## Custom digits

Ten inputs, one per digit. Member Counter replaces each digit of a number with the one you provide, which is how you print a counter in custom emojis or in a unicode style font.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-cdabeddf6d6cd93f2d500a8e08695e1c408ca43e%2Fsettings-custom-digits.png?alt=media" alt="The Custom digits section with its accessibility warning"><figcaption><p>The ten digit inputs, under the screen reader warning.</p></figcaption></figure>

**The default is the plain digits `0` to `9`**, already typed into the inputs. An empty input falls back to the plain digit for that position, so clearing one changes nothing. You do not have to fill all ten.

Each input is a small editor, not a plain text box. You can format the text inside it and open the emoji picker from it. A digit can be a custom server emoji, a channel mention or a role mention. It does not accept counters.

{% hint style="danger" %}
People using a screen reader may hear something unintelligible instead of your digits, and screen readers are often used by people with visual disabilities. Do not customise any digit unless you are certain that nobody using a screen reader will have access to a counter.
{% endhint %}

Custom digits only reach the **topic** of a text or announcement channel. The **name** of a voice channel or a category never shows them, because Discord channel names cannot. See [Formatting counters](/using-the-dashboard/customizing-counters#what-is-never-formatted) and [Channel settings](/using-the-dashboard/channel-settings).

## Saving

Click **Save** when you are done. Autosave is off by default and is switched on from [your account](/using-the-dashboard/account). The browser warns you if you leave the page with unsaved changes.

Saving does not rewrite your channels. The bot applies the new settings the next time it refreshes them, so a channel keeps its old text until then. See [how counters update](/using-the-dashboard/how-counters-update).

## Reset settings

**Reset settings** opens a confirmation dialog first.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-7fdad36386a2c0e8b6fe8844373955468d269ad7%2Fsettings-reset-dialog.png?alt=media" alt="The Reset settings confirmation dialog"><figcaption><p>The Reset settings confirmation</p></figcaption></figure>

{% hint style="danger" %}
**Reset settings does more than the dialog says.** It wipes the whole server record and builds a new one from the defaults, and that takes every channel with it. You lose the three settings on this page, **every template you have written, in every channel**, and the **Enable template** switch on each of them. On an upgraded server it also removes the server upgrade, which cannot be recovered. There is no undo.

If you only want the format defaults back, set them by hand instead: **Locale** to `en-US`, **Use compact notation for numbers** on, and the ten digit inputs back to the plain digits `0` to `9`.
{% endhint %}

<details>

<summary>Technical details</summary>

* Stored defaults on the guild row: `locale "en-US"`, `compactNotation true`, `digits ["0" … "9"]` (`packages/db/prisma/schema.prisma`).
* Editing needs `canModify`: the Discord `Administrator` or `ManageGuild` bit on that server, or the staff permission `ManageGuilds`.
* The update mutation takes `digits` as exactly ten strings and never validates `locale`.
* The locale list comes from `iso-lang-codes`, and a term typed by the user is accepted as it is, so any string reaches the database (`apps/frontend/src/lib/locales.ts`).
* Compact notation is `new Intl.NumberFormat(locale, { notation: "compact" })` and runs only when compact notation is on; an invalid tag throws a `RangeError` there. The job stores the raw message in `LastTemplateComputeError` and writes `jobs.updateChannels.computeError`, the string `ERROR!`, into the channel; the dashboard shows "An unknown error occurred." because it cannot parse that message. Evaluators fall back to `en-US` only when the stored locale is shorter than two characters, so `en_US` reaches Clock and Date unchanged and breaks them.
* Digit substitution runs only for `GuildText` and `GuildAnnouncement` channels; compact notation runs before that gate, so names keep it.
* **Reset settings** deletes the guild row and recreates it with the schema defaults. Channels are a relation of the guild, so every channel row, template and **Enable template** switch goes with it.
* Saved values reach a channel on the next cron pass, `0 */10 * * * *` on the public bot and `0 */5 * * * *` otherwise, 20 guilds at a time.
* Autosave fires 10,000 ms after the last edit.
* Self-hosting: `DISCORD_BOT_INSTANCE_IS_PREMIUM` picks the 5-minute interval and is `true` in `.env.example`. No environment variable changes the defaults above.

</details>


# Nested counters

Feed the result of one counter into another to build numbers no single counter can produce.

Most counter options are boxes you type into, and almost all of them take a counter instead. The inner counter runs first, and its result becomes the value of that option. That is a nested counter, and it is how you get numbers no single counter offers: members minus bots, or a value pulled from your own API and then formatted.

A nested counter is not a second chip in the template. It lives inside its parent, and the whole thing still shows as one chip.

## Which options accept a nested counter

Any option that takes free text or a number. Each counter's own page says which of its options qualify.

Options that are a fixed choice (**Operation type**, **Filter by status**, **Display**) take one of their listed values only. The digit boxes under **Override default digits** in **Format settings** take text and emoji, not counters.

## Adding one

1. Open the chip's edit panel and find the option you want to fill.
2. Click the option's box. The dropdown lists the same counters as **Add counter**, under the literal values the option already offers.
3. Pick a counter. The option now shows a row with the counter's name instead of a typed value.

Its gear opens the nested counter's own options, and its X takes it back out.

Where the gear has a tooltip, it says what the nested counter has to return there, for example a role ID on **Filter by role**. Nothing is checked while you edit, so a nested counter that returns the wrong kind of value only fails when the bot runs the template, see [Troubleshooting](/using-the-dashboard/troubleshooting).

{% hint style="warning" %}
The **Timezone** box on [Clock](/counters-reference/clock) and [Date](/counters-reference/date) also lists **Unknown**, an internal placeholder that makes the template fail with **The counter provided is unknown or not recognized.**
{% endhint %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-676d7e3f39c2a3bbfb018b0a12b5bd4c594b0136%2Feditor-nested-counter-row.png?alt=media" alt="A nested counter inside an option"><figcaption><p>A counter used as the value of an option, with its Edit counter and Remove counter buttons</p></figcaption></figure>

## The breadcrumb

Pressing the gear opens the nested counter, and a breadcrumb shows where you are. Each entry is the counter's generated chip name, not the counter's type: a **Math** counter with one nested counter reads `Add and 1 more`.

* Click an earlier entry to jump straight back to it.
* The back arrow goes up one level.
* The X closes the panel entirely.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-ee4a3ddb81f81006a7f2bfc3edbb3b9d740665d8%2Feditor-nested-breadcrumb.png?alt=media" alt="The nesting breadcrumb"><figcaption><p>The breadcrumb shows where you are; earlier entries are clickable</p></figcaption></figure>

## Formatting comes from the outermost counter

A nested counter hands its raw value to its parent. Compact notation, the locale and custom digits are applied **once**, to the final value of the outermost counter in the chip. That is why the **Format settings** block keeps editing the outermost counter even while you are drilled several levels down: a nested counter has no formatting of its own.

So to get `1.2M` instead of `1234567`, set compact notation on the counter at the top of the stack, not on the one that produces the number. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Examples

{% tabs %}
{% tab title="Math over Members" %}
**Goal:** show how many members are *not* staff.

There is no "members without a role" filter, so subtract one **Members** count from another.

1. Press **Add counter** and pick [Math](/counters-reference/math).
2. Set **Operation type** to **Subtraction**.
3. In **Number list**, add a counter and pick **Members**. Leave every filter empty, so it counts the whole server.
4. Add a second counter to the list and pick **Members** again. Press its gear, set **Filter by role** to @Staff, and go back with the breadcrumb.
5. Type `non-staff` after the chip.

The channel shows the number of members without the role, followed by `non-staff`.

**Order matters.** Subtraction and division fold the list from first to last with no starting value, so the first entry is the one everything else is taken away from. Addition and multiplication do not care about order.

Give each **Members** chip a [display label](/using-the-dashboard/template-editor#display-label) such as `All` and `Staff`, or both rows will show almost the same generated name.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5fcb42cdb5d03e34d5b347bc3d9bc4d1d6e971de%2Fnested-example-math-members.png?alt=media" alt="A Math counter subtracting two Members counters"><figcaption><p>The Math counter with its two Members rows, and the second one filtered by role</p></figcaption></figure>
{% endtab %}

{% tab title="Replace over HTTP" %}
**Goal:** turn a status word from your own API into something readable.

Your endpoint returns `{"status":"ok"}` and you want the channel to say `🟢 Online`.

1. Press **Add counter** and pick [Replace](/counters-reference/replace).
2. Click **Input text** and pick **HTTP** from the list.
3. Press the gear on the HTTP row. Set **URL (GET)** to your endpoint and **Data path** to `status`. Set **Lifetime (in seconds)** to `60` so the bot does not hit your API on every run. Go back with the breadcrumb.
4. Press **Add replacement**. Put `ok` in **Search for** and `🟢 Online` in **Replace with**.

The channel reads `🟢 Online`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-da9fba540e8fb1fe5f97864333f4bda9af0b4be4%2Fnested-example-replace-http.png?alt=media" alt="A Replace counter wrapping an HTTP counter"><figcaption><p>The Replace counter with the HTTP counter as its input, and one replacement</p></figcaption></figure>

{% hint style="warning" %}
A replacement with an empty **Replace with** is ignored, and so is a single space, so Replace cannot delete text.
{% endhint %}
{% endtab %}

{% tab title="Number over HTTP" %}
**Goal:** make a value from your own API obey your formatting.

The [HTTP counter](/counters-reference/http) always returns text, even when the text happens to be digits. Text is passed through untouched, so compact notation, the locale and your custom digits never apply to it.

Wrap it in [Number](/counters-reference/number) to turn it back into a number:

1. Press **Add counter** and pick **Number**.
2. Click the **Number** box and pick **HTTP** from the list.
3. Press the gear, configure the URL and data path, then go back.
4. Open **Format settings** on the **Number** chip and set **Use compact notation** to **Yes**.

The channel now shows the value in compact notation.

The same trick works for any counter that produces text, and it is the reason the **Number** counter exists.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-80d7692ba31ee8a5cbcf1ccb7432bda1ec7d498f%2Fnested-example-number-http.png?alt=media" alt="A Number counter wrapping an HTTP counter"><figcaption><p>Number turns the HTTP counter's text into a number, so Format settings apply</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Limits

Nothing caps nesting depth or the number of counters directly, but a very deep tree eventually trips the explorer's step limit and fails with **An unknown error occurred.**

A chip and everything nested inside it must finish within 60 seconds. The usual culprit is a [Game](/counters-reference/game) counter pointing at a server that does not answer, or a long chain of nested requests. [HTTP](/counters-reference/http) requests give up after 5 seconds on their own.

<details>

<summary>Technical details</summary>

* The evaluator (`packages/services/src/DataSource/index.ts`) walks the chip's object tree and runs a node once it has no unexplored object children, replacing it with its result. The walk stops after 50,000 steps and throws a plain `Error`, which the dashboard shows as **An unknown error occurred.** Exploring one nested counter costs several steps.
* The 60-second timeout is set per delimited counter in the template, not per nested counter, so the whole tree shares it. A timeout raises `EVALUATION_TIMEOUT`.
* Format settings are resolved once, from the chip's root counter, and the same `formatSettings` object is handed to every nested evaluation: per-counter override, then the server setting, then the built-in default, per field. Digit substitution runs on the final value only, and only for a text or announcement channel.
* The counter list offered inside an option has the internal `UNKNOWN` entry filtered out. Clock's and Date's **Timezone** box is the exception: it is built from the unfiltered list, which is why **Unknown** appears there and nowhere else. Picking it fails with `UNKNOWN_DATA_SOURCE`.
* Gear tooltips are per-option locale strings; Replace, Concat and Members' **Filter by playing a game** pass none, so their gear shows no tooltip.
* Nothing bounds the template: neither the API input nor the database column sets a maximum length. An unset template is stored as an empty string, not null.

</details>


# How counters update

How often the bot refreshes channels, and why a value can lag behind.

Counters are not live. Every few minutes the bot works out what each template should say, then renames the channel or rewrites the channel topic if the result changed.

If a value is not just late but wrong or missing, go to [Troubleshooting](/using-the-dashboard/troubleshooting).

## How often a channel is refreshed

| Bot                                           | Refresh interval                               |
| --------------------------------------------- | ---------------------------------------------- |
| Public bot ("Member Counter")                 | Every 10 minutes                               |
| Legacy premium bot ("Member Counter Premium") | Every 5 minutes                                |
| [Custom bot](/custom-bot/custom-bot)          | Every 5 minutes                                |
| [Self-hosted bot](/self-hosting/self-hosting) | Every 5 minutes with the default `.env` values |

Each run works through the servers in turn, so your channel is reached partway into it and the gap can be longer.

{% hint style="info" %}
The bot does not refresh channels when it starts up. After a restart, the first refresh happens at the next tick of the schedule.
{% endhint %}

## Unchanged values are skipped

The bot writes to Discord only when the new result differs from what the channel already says. **Last updated:** on the channel page still moves, because the bot did process the template.

## Member counts on the public and legacy premium bots

Neither the public bot nor the legacy premium bot can read the member list. Each asks Discord for approximate member and presence counts instead, every 20 minutes. So the count can be up to 20 minutes old when the channel is refreshed, and a new member can take up to half an hour to show up. A custom bot and a self-hosted bot read the real member list, so their counts are current at the moment the channel is refreshed.

See [Members](/counters-reference/members) for which filters each bot supports.

## Which bot updates your server

A server can hold more than one Member Counter bot, for example the public bot and your own [custom bot](/custom-bot/custom-bot). Only one of them updates the counters, and the custom bot has priority.

* Add a custom bot and it takes over the counters within a few minutes.
* Remove it, or let it stop, and the public bot picks the server back up. That takes about 30 minutes.

Your settings are kept either way. What changes is the refresh interval, and whether counters that need privileged intents work.

## Counters that cache their own data

Counters that call an outside service reuse the answer for a while instead of calling it on every refresh.

| Counter                                    | How long a value is reused                                                                    |
| ------------------------------------------ | --------------------------------------------------------------------------------------------- |
| [Twitch](/counters-reference/twitch)       | 15 minutes                                                                                    |
| [Reddit](/counters-reference/reddit)       | 15 minutes                                                                                    |
| [Memerator](/counters-reference/memerator) | 30 minutes                                                                                    |
| [YouTube](/counters-reference/youtube)     | 60 minutes                                                                                    |
| [HTTP](/counters-reference/http)           | The **Lifetime (in seconds)** you set. Leave it empty and the URL is fetched on every refresh |

Every other counter is computed on each refresh.

{% hint style="warning" %}
The [Game](/counters-reference/game) counter is not cached. The bot queries the game server on every refresh, for every counter channel that watches it.
{% endhint %}

## Clock and Date round to 5 minutes

[Clock](/counters-reference/clock) and [Date](/counters-reference/date) round the current time to the nearest 5 minutes, not down. A clock never shows real seconds, and it changes at most once every 5 minutes. Rounding to the nearest value also means it can read up to 2 minutes 30 seconds ahead of the real time. On the public bot it usually moves in 10-minute steps, because the bot never writes the 5-minute values in between.

## Discord's own rate limit

Discord limits how often a channel can be renamed, per channel. A counter whose value changes on nearly every refresh, such as a fast [Countdown](/counters-reference/countdown), gets throttled. The bot queues the edit and it goes through as soon as Discord accepts it, so the value arrives late rather than not at all.

Moving the counter to a channel topic does not get around this. The bot writes the name and the topic with the same Discord call.

## Checking when a channel was last processed

Open the channel in the dashboard. **Last updated** is the time the bot last processed that channel.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-89d08de1bc5da83d2b8b3c0385c73217535ddc46%2Fdashboard-channel-last-updated.png?alt=media" alt="Channel page with the Last updated time"><figcaption><p>The channel page tells you when the bot last processed this template.</p></figcaption></figure>

* Minutes old, but the channel still shows an old value: the value did not change, so the bot skipped the write.
* Hours old: the bot is not processing the channel at all. See [Troubleshooting](/using-the-dashboard/troubleshooting).
* **Last updated: Unknown date**: the bot has never processed this channel. That is what a new counter shows until the first refresh reaches it.

<details>

<summary>Technical details</summary>

* Channel refresh cron: `0 */5 * * * *` when the bot instance is premium, `0 */10 * * * *` when it is not. Self-hosting: `DISCORD_BOT_INSTANCE_IS_PREMIUM` ships as `true`, set it to `false` and the interval becomes 10 minutes.
* A tick is dropped while the previous run of the same job is still going. It does not queue behind it.
* Up to 20 servers are processed at the same time (`GUILD_CONCURRENCY`), and all counter channels of one server are processed together.
* A run takes a Redis FIFO queue plus a Redlock on the Discord-intensive operation key, 60 s TTL, extended every 30 s. The approximate member count job takes the same lock, so the two take turns and either can delay the other.
* The computed value is cut to 1023 characters for a topic and to 99 characters for a name, then compared against `channel.topic` for text and announcement channels and against `channel.name` for voice channels and categories. Equal means no API call at all.
* An Unknown Channel (404) during an update disables that channel's template, so the job stops retrying a deleted channel.
* Approximate member and presence counts: `0 */20 * * * *`, 10 servers at a time, also runs on startup, disabled on privileged bots.
* Evaluator priority: every bot advertises its priority for each of its servers every 5 minutes, also on ready and on a Redis reconnect, and the key expires after 900 s. The highest advertised value wins, and a bot handles a server only when the advertised priority is `<=` its own. `DISCORD_BOT_INSTANCE_COMPUTE_PRIORITY` is `0` on a self-hosted bot; custom bots are launched with `2`.
* A server with no current advertisement is skipped by every bot. Worst case for a handover back to the public bot: 15 minutes for the expiry, 5 minutes for the next advertisement, then one refresh interval.
* Cache lifetimes sit in each counter's evaluator under `packages/services/src/DataSource/DataSourceEvaluator/evaluators/`. Values are stored in Redis under a key built from the counter and its subject, not the server, so two servers counting the same subreddit share one value, and a Redis failure counts as a cache miss.
* Clock and Date round with `Math.round` over a 5-minute coefficient. In Clock that makes `%S` always render `00` and `%s` always render `0`; Date has no second placeholders.

</details>


# Your account

Your badges, the Autosave switch, credit, server upgrades and how to delete your account.

Open your account page from **Account** in the top bar of the dashboard.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e5581b6f0ee46dc9396d10eb022670d8253b5af9%2Faccount-overview.png?alt=media" alt="The account page"><figcaption><p>Avatar, username, badges and <strong>Logout</strong> at the top; the <strong>Settings</strong> card and the <strong>Danger zone</strong> below.</p></figcaption></figure>

## Who you are

Nothing at the top of the page is editable: change your name or avatar in Discord and this page follows. An account that has not moved to the new Discord username system still shows its four-digit discriminator after the name, for example `#1234`.

## Badges

Your badges sit under your username, as a row of emoji.

The full list, and how each badge is earned, is on the [badges](/contributing/badges) page.

## Settings

The **Settings** card holds one setting, **Autosave**, which saves dashboard forms for you without clicking **Save**.

**It is off by default.** Your account stores it, not your browser, so it follows you to every device you sign in from. It applies to every dashboard form that has a **Save** button: [Server settings](/using-the-dashboard/server-settings), the [channel page](/using-the-dashboard/channel-settings) that holds the [template editor](/using-the-dashboard/template-editor), and, on the hosted service, a [custom bot](/custom-bot/control-panel)'s configuration.

With Autosave on, a form saves by itself shortly after you stop typing, and clicking the button still saves right away. Either way, leaving a page with unsaved changes asks you to confirm.

## Danger zone

**Delete account** asks you to confirm first.

{% hint style="danger" %}
There is no undo and no grace period. It deletes your account, your custom bots, your credit balance and your unspent server upgrades.
{% endhint %}

To delete your data but keep your account, use the **Delete data** button of the [`/profile`](/commands/profile) command in Discord. There is no button for that on the dashboard.

## Credit

Your credit balance, in euros, shows here when it is not zero.

Credit pays for a [custom bot](/custom-bot/custom-bot) instead of PayPal. It is granted by staff in special cases only; you cannot buy it. See [managing your subscription](/custom-bot/managing-your-subscription) for how credit is spent.

Your custom bots are listed here too, each with a link to its [control panel](/custom-bot/control-panel).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-fccf739098d113b99c67e15a235711cb21f4cd49%2Faccount-credit.png?alt=media" alt="The Credit card"><figcaption><p>The <strong>Credit</strong> card with the custom bots of the account. The amount comes from a test account.</p></figcaption></figure>

{% hint style="info" %}
Credit and server upgrades are part of the hosted service. Neither exists on a [self-hosted bot](/self-hosting/self-hosting).
{% endhint %}

## Available server upgrades

Server upgrades are the old way to get premium, from before custom bots existed. This section shows how many you have left, and it also stays visible if a server of yours is already upgraded, so you can re-invite the legacy premium bot there.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-7ec681addea41daa2fab6b0024bdb6ffe7135d1a%2Faccount-server-upgrades.png?alt=media" alt="The Available server upgrades card"><figcaption><p>The <strong>Available server upgrades</strong> card, with the tooltip of the <strong>Upgrade server</strong> button</p></figcaption></figure>

| Button                     | What it does                                                                                                                               |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **Invite premium bot**     | Opens the legacy premium bot's invite for that server. It is only there once the server is upgraded.                                       |
| **Upgrade server**         | Opens a separate confirmation page for that server. It is disabled when the server is already upgraded or you do not have enough upgrades. |
| **Go to server dashboard** | Opens that server in the dashboard.                                                                                                        |

{% hint style="danger" %}
Upgrading a server spends one upgrade and **cannot be reversed**.
{% endhint %}

Server upgrades are the old system and no new ones are sold. If you are choosing today, read [legacy premium vs custom bot](/custom-bot/legacy-premium-vs-custom-bot) first.

<details>

<summary>Technical details</summary>

* The username shows `#discriminator` only when it is not `"0"`.
* Badges are a `BigInt` bitfield on the user row; the row is hidden when it is `0`.
* **Autosave** is `prefersAutosave` on the user row, `@default(false)`, read from the session.
* Autosave waits `AUTOSAVE_DEBOUNCE_MS = 10_000` after the last edit, and every edit pushes the deadline back.
* In-app navigation asks for confirmation through `window.confirm`; closing or reloading the tab goes through `beforeunload`, where the browser shows its own wording. The blocker is off in dev builds.
* **Delete account** calls the `user.delete` mutation, which runs `db.user.delete`, then navigates to `/logout`. The user row holds the Discord user id, badges, staff permissions, the autosave preference and the donation links; server settings and channel templates hang off the guild row and survive.
* The site language is not on this page. It is chosen in the top bar: 17 languages, taken from `?lang=`, then the `language` cookie, then the browser, falling back to `en-US` (`apps/frontend/src/lib/i18n/index.ts`).
* Credit and server upgrades live only in the hosted (BIZ) build, as `credit` (EUR cents) and `legacyCredit` (an `Int`) on the user row. `upgradeLegacyPremiumStatus` decrements `legacyCredit` by one and sets `legacyPremiumStatus` on the guild, refusing when the server is already upgraded or the balance is zero. Credit is granted only by the admin `user.update` mutation, behind `ManageUsers`. It is spent automatically on each paid subscription cycle and given back when a transaction is refunded.
* **Invite premium bot** invites the client id in `DISCORD_LEGACY_PREMIUM_CLIENT_ID` (`688714342392135731` in the hosted `.env.example`). Self-hosting has no such code path.

</details>


# Troubleshooting

What each error means and how to fix it.

The table at the end lists every error message the bot can show.

## Start here

Open the channel's page in the [dashboard](https://membercounter.app/dashboard) first: almost every problem shows up there. Pick the server, then pick the channel in the list on the left.

* **Enable template**: if this is off, the bot ignores the channel completely.
* **Last updated:** when the bot last processed the template. Minutes old means the bot is working. Hours old means it is not reaching the channel. **Unknown date** means it has never processed the channel: check that **Enable template** is on and that the bot is in the server.
* The dot next to the channel in the list: a coloured dot marks a channel with **Enable template** on, and it turns red when that channel has an issue. A channel with the template off has no dot, and a red dot clears itself on the next successful refresh.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-08139e09edd1ed1a0f95069a3329c5cc83a47c8d%2Fdashboard-channel-nav-issue-marker.png?alt=media" alt="Channel list with a red issue marker"><figcaption><p>A red dot in the dashboard channel list marks a channel that needs attention.</p></figcaption></figure>

## The channel shows ERROR!

**Symptom.** The channel name, or the channel topic, is a short error word and nothing else.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-37ae390c0ec4068a190334e29a9db5dccef3ec6a%2Fdiscord-channel-error.png?alt=media" alt="A counter channel renamed to the error placeholder"><figcaption><p>When a template cannot be processed, the bot writes a placeholder in the server's language in place of the value.</p></figcaption></figure>

**Cause.** The template could not be processed, so the bot wrote a placeholder instead of leaving a stale value in place. The real reason is only shown on the dashboard.

The word follows your server's language in Discord, so it may not read `ERROR!`.

**Fix.**

1. Open the channel in the dashboard.
2. Read the message in the red box under **The template had an error when it was last processed**.
3. Look it up in [the table at the end of this page](#error-messages) and apply the fix.
4. Wait for the next refresh. See [How counters update](/using-the-dashboard/how-counters-update) for the interval.

{% hint style="info" %}
The bot writes the template result to the topic of text and announcement channels, and to the name of voice channels and categories. A placeholder means the same thing in both.
{% endhint %}

## A counter is not updating

The channel shows a value, but it is out of date. Work through these in order.

1. **The value did not change.** The bot only writes when the new result differs from what the channel already says. If **Last updated:** is recent, the bot had nothing new to write.
2. **You are looking too soon.** The public bot refreshes every 10 minutes, and its member counts come from an approximate figure that is only re-fetched every 20 minutes. See [How counters update](/using-the-dashboard/how-counters-update).
3. **Another bot took the server over.** A [custom bot](/custom-bot/custom-bot) handles the counters instead of the public bot. If it is offline, the counters stop until the public bot picks the server back up, which takes about 30 minutes. See [Which bot updates your server](/using-the-dashboard/how-counters-update#which-bot-updates-your-server).
4. **The template is disabled.** Turn **Enable template** back on for that channel. See [Channel settings](/using-the-dashboard/channel-settings).
5. **The counter caches its value.** Twitch and Reddit reuse a value for 15 minutes, Memerator for 30, YouTube for 60, and an [HTTP](/counters-reference/http) counter for the **Lifetime (in seconds)** you set.
6. **Discord is throttling the rename.** Channel renames are rate limited per channel. The bot's edit is queued and goes through once Discord accepts it. Using a channel topic does not help.
7. **It is a** [**Clock**](/counters-reference/clock) **or a** [**Date**](/counters-reference/date)**.** Both round to the nearest 5 minutes, so they never show seconds and change at most every 5 minutes. See [Clock and Date round to 5 minutes](/using-the-dashboard/how-counters-update#clock-and-date-round-to-5-minutes).

## The bot cannot edit the channel

**Symptom.** A red alert on the channel page: **The bot does not have permission to edit this channel.** The channel keeps its old value.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-1851ef980e2e32c31d7963c4997b6ab394afc6dd%2Fdashboard-missing-permissions.png?alt=media" alt="Missing permissions alert on the channel page"><figcaption><p>The dashboard lists the permissions the bot needs in that channel; check each one yourself.</p></figcaption></figure>

**Cause.** The bot checks its permissions in that specific channel before editing. The alert does not say which one is missing: it lists what the bot needs, and you compare that list against the channel yourself.

| Permission         | Needed for                                                                |
| ------------------ | ------------------------------------------------------------------------- |
| **View Channel**   | Every counter channel                                                     |
| **Manage Channel** | Every counter channel. This is what allows the rename or the topic change |
| **Connect**        | Voice channels only                                                       |

A server-wide permission is not enough. A channel overwrite, or a category overwrite the channel inherits, can take the permission away again for that one channel.

**Fix.** In Discord, open the channel's settings, then **Permissions**. Check the bot's role and every overwrite that applies to it, including the category the channel sits in. Back in the dashboard, press **Check again**.

{% hint style="warning" %}
The bot never reports this in Discord, so a permissions problem is silent until you look at the dashboard.
{% endhint %}

## "The template had an error when it was last processed"

**Symptom.** That label on the channel page, with the error message in a box below it.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e09b4fc3983c1b914a9b54ccbd16559170ebbcea%2Fdashboard-template-error.png?alt=media" alt="Template error on the channel page"><figcaption><p>The red box carries the exact message; look it up in the table below.</p></figcaption></figure>

**Cause.** This is the record of the last processing attempt, and it covers two cases:

* The template failed outright, and the channel now shows [the error placeholder](#the-channel-shows-error).
* The template succeeded but one counter fell back to a default. The channel updated correctly, and the message says what was skipped. This is what an [HTTP](/counters-reference/http) counter with a **Fallback value** does when its request fails.

So the message on its own does not mean the counter is broken. Compare it with the channel's current value.

**Fix.** Find the message in the table below. It is rewritten on the next processing attempt and disappears once the template works cleanly.

## The bot is offline

* **The public bot is restarting.** Counters resume at the next scheduled tick, up to a full interval later.
* **Your custom bot is stopped.** Start it again from the [control panel](/custom-bot/control-panel), and check the subscription in [Managing your subscription](/custom-bot/managing-your-subscription).
* **Your custom bot will not start.** The dashboard shows **Bot failed to start (check the token or intents)**. Turn on PRESENCE INTENT and SERVER MEMBERS INTENT in your own application, as described in [Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account), then start the bot again.
* **Your self-hosted bot is down.** Check the container logs. See [Updating a self-hosted bot](/self-hosting/updating-a-self-hosted-bot) and the [configuration reference](/self-hosting/configuration).
* **The bot was removed from the server.** Invite it again from [Adding the bot to the server](/getting-started/adding-bot-to-the-server). Your counters are stored server-side and come back with it.

## A command replies with ERROR!

**Symptom.** A slash command answers with an error embed: **Something went wrong, please try again later.** Copy the **Error ID** below it into your support request. The embed is written in your own Discord language, so it may not be in English.

**Cause.** The command itself failed. That is separate from counters failing.

**Fix.** Retry once. If it fails again, report it in the [support server](https://membercounter.app/support) with the command you ran, your server ID and roughly what time it was. The maintainers match that against the bot's logs.

## Error messages

These are the messages the bot shows under **The template had an error when it was last processed**. The wording is exact.

{% hint style="info" %}
For an [HTTP](/counters-reference/http) counter, set a **Fallback value**: the channel keeps a sensible value when the request fails, and the failure is still reported here. **Lifetime (in seconds)** is not a fix, it only controls how long a successful response is cached.
{% endhint %}

| Message                                                                                                             | What to do                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The bot does not have sufficient permissions to edit the channel.                                                   | Fix the channel and category overwrites, then press **Check again**. See [above](#the-bot-cannot-edit-the-channel)                                                                                                                                                                                                                                                                                                                            |
| The result for the channel name is less than 2 characters long.                                                     | Discord will not accept a name that short. Add fixed text around the counter, for example `Members: {counter}`. Voice channels and categories only                                                                                                                                                                                                                                                                                            |
| The evaluation took too long and timed out.                                                                         | One counter took more than 60 seconds. Usually a [Game](/counters-reference/game) counter pointing at an unreachable server, or a template stacking a lot of [nested counters](/using-the-dashboard/nested-counters). Check the target and cut the template down                                                                                                                                                                              |
| The bot does not have enough privileged intents to use this counter.                                                | On the legacy premium bot this is expected: it has no privileged intents, so the finer Members filters need a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting). On those two, turn on PRESENCE INTENT and SERVER MEMBERS INTENT in your own application, then start it again. See [Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account)                                         |
| The bot is not a premium version and lacks certain features needed for this counter.                                | The counter needs a privileged bot, and the public bot is handling this server. Simplify the counter, or move to a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting). See [Members](/counters-reference/members) for the filters the public bot supports                                                                                                                                                |
| The member counts are not available at this moment.                                                                 | Discord has not given the bot an approximate member count yet. Wait: the public bot re-fetches every 20 minutes. If it lasts for hours, re-invite the bot                                                                                                                                                                                                                                                                                     |
| The Twitch counter requires a username to be set.                                                                   | Fill in **Username** on the [Twitch](/counters-reference/twitch) counter                                                                                                                                                                                                                                                                                                                                                                      |
| The specified Twitch channel could not be found.                                                                    | Check the spelling. Use the login name from the channel's URL, not its display name                                                                                                                                                                                                                                                                                                                                                           |
| The YouTube counter requires a channel URL to be set.                                                               | Paste the channel address into the [YouTube](/counters-reference/youtube) counter                                                                                                                                                                                                                                                                                                                                                             |
| The provided YouTube channel URL is invalid.                                                                        | Open the channel on YouTube and copy the URL from the address bar                                                                                                                                                                                                                                                                                                                                                                             |
| A subreddit must be provided for the Reddit counter.                                                                | Enter the subreddit name without `r/` on the [Reddit](/counters-reference/reddit) counter                                                                                                                                                                                                                                                                                                                                                     |
| The Memerator counter requires a username, which is missing.                                                        | Enter the [Memerator](/counters-reference/memerator) username                                                                                                                                                                                                                                                                                                                                                                                 |
| The HTTP counter requires a URL, which is missing.                                                                  | Enter the full URL, including `https://`                                                                                                                                                                                                                                                                                                                                                                                                      |
| The content type of the HTTP response is invalid (not text/plain or application/json).                              | The URL answered with something else, typically an HTML page. Point the counter at an endpoint that returns JSON or plain text                                                                                                                                                                                                                                                                                                                |
| The HTTP response status code is invalid (not 200).                                                                 | The server answered with an error or a redirect. Open the URL in a browser, follow any redirect yourself and use the final address                                                                                                                                                                                                                                                                                                            |
| A data path must be specified for HTTP requests whose content type is application/json.                             | Set **Data path** to the value you want, for example `data.count`                                                                                                                                                                                                                                                                                                                                                                             |
| The game counter requires a server address.                                                                         | Enter the game server's hostname or IP                                                                                                                                                                                                                                                                                                                                                                                                        |
| The game counter requires a server port.                                                                            | Enter the query port for that game                                                                                                                                                                                                                                                                                                                                                                                                            |
| A game ID must be provided for the game counter.                                                                    | Pick the game in the counter's options                                                                                                                                                                                                                                                                                                                                                                                                        |
| The counter provided is unknown or not recognized. The counter is in a unrecognized format. The counter is invalid. | The stored counter could not be read. Rebuild it in the [template editor](/using-the-dashboard/template-editor). On a [self-hosted bot](/self-hosting/self-hosting) it can also be an old template on a version that is behind: update the bot                                                                                                                                                                                                |
| The type of value returned from evaluation is unknown.                                                              | A [nested counter](/using-the-dashboard/nested-counters) is plugged into an option that cannot take one. Hovering the gear button on a nested counter's row says what that option must return                                                                                                                                                                                                                                                 |
| Failed to generate a final string from the evaluation.                                                              | A counter that needs a number was given text, for example a [Math](/counters-reference/math) counter. Check the inputs of your value counters                                                                                                                                                                                                                                                                                                 |
| An unknown error occurred.                                                                                          | The catch-all. Check for a slow or unreachable [HTTP](/counters-reference/http) endpoint, a **Data path** that does not match the response, a [Game](/counters-reference/game) server that does not answer, or a **Timezone** from a [nested counter](/using-the-dashboard/nested-counters) that is not a real identifier such as `Europe/Madrid`. If none of those fit, report it in the [support server](https://membercounter.app/support) |

## Still stuck

Bring these three things to the [support server](https://membercounter.app/support) and someone can help quickly:

* Your server ID and the channel that is misbehaving.
* The exact message from the red error box, or the command you ran and roughly when.
* Whether the server is on the public bot, a custom bot or a self-hosted bot.

<details>

<summary>Technical details</summary>

* The channel placeholder is the `jobs.updateChannels.computeError` string, picked by `guild.preferredLocale` (the server's language in Discord, not the dashboard account language), with 16 translations; anything else falls back to `ERROR!`.
* The red box holds an error code stored in the channel log and translated in the browser. Any stored code that is not one of `DataSourceErrorNames` is rendered as **An unknown error occurred.**, which is why the catch-all row covers unrelated failures.
* The bot's strings live in `apps/bot/src/locales/<lang>/main.json` and the dashboard's error strings in `apps/frontend/src/locales/en-US/main.json` under `common.knownErrors`.
* A self-hosted bot missing `YOUTUBE_API_KEY`, `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`, or `MEMERATOR_API_KEY` throws a plain `Error` in the evaluator, so those counters land on **An unknown error occurred.** instead of a specific message.
* The 60 s evaluation timeout is per counter part and covers everything nested inside it.
* Outgoing requests from the HTTP, Reddit, YouTube and Memerator counters abort after 5 seconds, so a slow endpoint reports its own error rather than the evaluation timeout.
* The permission check combines the bot's role permissions with every overwrite that applies to that channel. A failure is written to the channel log as `NO_ENOUGH_PERMISSIONS_TO_EDIT_CHANNEL` and the channel is skipped.
* A template that evaluates with non-fatal errors still updates the channel, and the first of those errors is what the red box shows.

</details>


# All counters

Every counter the bot can put in a channel, what it shows, and which bot you need to run it.

A counter is one piece of live data inside a [template](/using-the-dashboard/template-editor). The bot replaces each counter with its current value every time it rewrites the channel, so a channel called `Members: 1204` is really a template with one Members counter in it. How the value looks is up to you. See [Formatting counters](/using-the-dashboard/customizing-counters).

You add counters in the template editor with **Add counter**, and type in the box to filter the list.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3c0c5249a6584a190f8de92add2170c472e6fb18%2Feditor-add-counter-list.png?alt=media" alt="The Add counter picker"><figcaption><p>The <strong>Add counter</strong> picker, scrolled to the top</p></figcaption></figure>

## How to read the availability column

| Availability        | What it means                                                                                                                                                                                                                                           |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every bot           | Works on the public bot, the legacy premium bot, a custom bot and a self-hosted bot, with no extra setup.                                                                                                                                               |
| Custom bot          | Needs a [custom bot](/custom-bot/custom-bot) or a self-hosted bot. The legacy premium bot runs Twitch and YouTube, but not the finer Members filters, since it has no privileged intents. Nothing in this row works on the public "Member Counter" bot. |
| Self-hosted needs … | Extra keys you must put in `.env` when you run the bot yourself. See the [configuration reference](/self-hosting/configuration).                                                                                                                        |

Neither the public bot nor the legacy premium bot has privileged intents, so anything that needs to see individual members or their presence is limited on both. A [custom bot](/custom-bot/how-to-get-a-custom-bot) runs under your own token on our hosting with the intents enabled; [self-hosting](/self-hosting/self-hosting) is the free alternative on your own machine.

## Server

Counters that read the Discord server the channel is in.

| Counter                                              | What it shows                                                                                                                            | Availability                                                                                                                                                                                                                                                          |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Members](/counters-reference/members)               | Member count, optionally filtered by online status, roles, account type and more                                                         | Every bot for **Any**, **Online**, **Online + Idle + DND**, **Offline** and **Count only banned members**. The **Idle**, **Do not disturb**, account type, role, game and voice filters need a custom bot or a self-hosted bot; the legacy premium bot cannot do them |
| [Roles](/counters-reference/roles)                   | How many roles the server has (to count the members who hold a role, use [Members](/counters-reference/members) with **Filter by role**) | Every bot                                                                                                                                                                                                                                                             |
| [Channels](/counters-reference/channels)             | How many channels the server has (categories are not counted), or how many are inside chosen categories                                  | Every bot                                                                                                                                                                                                                                                             |
| [Nitro Boosters](/counters-reference/nitro-boosters) | The number of Nitro boosters of the server                                                                                               | Every bot                                                                                                                                                                                                                                                             |

On the public bot and on the legacy premium bot, **Online** and **Online + Idle + DND** return the same figure. Both read Discord's approximate presence count, which already includes idle and do-not-disturb members.

## Time

Counters that show a clock, a date or a countdown. A channel is rewritten every 5 or 10 minutes, so a clock is never to-the-second. Clock and Date also snap to the nearest 5 minutes, so their value moves in 5-minute steps. See [How counters update](/using-the-dashboard/how-counters-update).

| Counter                                    | What it shows                             | Availability |
| ------------------------------------------ | ----------------------------------------- | ------------ |
| [Clock](/counters-reference/clock)         | The current time in the timezone you pick | Every bot    |
| [Date](/counters-reference/date)           | The current date in the timezone you pick | Every bot    |
| [Countdown](/counters-reference/countdown) | The time left until a date you set        | Every bot    |

## External services

Counters that fetch a number from outside Discord. All of them cache their answer, so the value lags behind the source. Every counter page says for how long.

| Counter                                    | What it shows                                                                       | Availability                                                                                                        |
| ------------------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| [Twitch](/counters-reference/twitch)       | The follower count, live viewer count or channel name of a Twitch channel           | Custom bot. Self-hosted needs `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` and `DISCORD_BOT_INSTANCE_IS_PREMIUM=true` |
| [YouTube](/counters-reference/youtube)     | Subscriber count, video count, total views or the channel name of a YouTube channel | Custom bot. Self-hosted needs `YOUTUBE_API_KEY` and `DISCORD_BOT_INSTANCE_IS_PREMIUM=true`                          |
| [Reddit](/counters-reference/reddit)       | The title of a subreddit, its member count and how many members are online          | Every bot                                                                                                           |
| [Memerator](/counters-reference/memerator) | The follower count or meme count of a Memerator account                             | Every bot. Self-hosted needs `MEMERATOR_API_KEY`                                                                    |
| [Game](/counters-reference/game)           | The number of online players on a game server, for more than 320 games              | Every bot                                                                                                           |
| [HTTP](/counters-reference/http)           | Any value from a GET request to an endpoint you choose                              | Every bot                                                                                                           |

## Building blocks

Counters that shape values rather than fetch them. Each one accepts typed-in values as well as other counters nested inside it. See [Nested counters](/using-the-dashboard/nested-counters).

| Counter                                | What it shows                                                                    | Availability |
| -------------------------------------- | -------------------------------------------------------------------------------- | ------------ |
| [Math](/counters-reference/math)       | Addition, subtraction, multiplication, division or modulo over a list of numbers | Every bot    |
| [Number](/counters-reference/number)   | A number with formatting applied to it                                           | Every bot    |
| [Concat](/counters-reference/concat)   | Several strings joined into one                                                  | Every bot    |
| [Replace](/counters-reference/replace) | Text with parts of it replaced                                                   | Every bot    |

## Other

| Counter                                    | What it shows                                   | Availability |
| ------------------------------------------ | ----------------------------------------------- | ------------ |
| [Bot stats](/counters-reference/bot-stats) | How many servers and users the bot itself is in | Every bot    |

<details>

<summary>Technical details</summary>

* **Add counter** matches what you type against each counter's numeric id and its `keywords` string from the locale files (`apps/frontend/src/locales/en-US/main.json`), not against the name or the description shown in the row.
* A template stores each counter by its numeric `DataSourceId` (`packages/common/src/DataSource.ts`), and that number is also the middle part of the counter's Redis cache key, `1:<id>:<argument>`, where `1` is `BaseKeys.DataSourceCache`.
* The "Custom bot" rows are enforced in the evaluators. `Twitch.ts` and `YouTube.ts` assert `isPremium` and throw `BOT_IS_NOT_PREMIUM`, "The bot is not a premium version and lacks certain features needed for this counter." `members.ts` throws the same error for a filter the instance cannot serve, or `BOT_HAS_NO_ENOUGH_PRIVILEGED_INTENTS`, "The bot does not have enough privileged intents to use this counter.", when the instance is premium but the intent is off.
* The channel rewrite job is a cron: `0 */5 * * * *` on a premium instance, `0 */10 * * * *` otherwise (`apps/bot/src/jobs/updateChannels.ts`).
* Each counter in a template gets its own 60 seconds, nested counters included in their parent's. The parts are evaluated one after another, so a template with several counters can take longer overall. Past 60 seconds a counter fails with `EVALUATION_TIMEOUT`, "The evaluation took too long and timed out."
* Templates from the old bot were converted counter by counter by the transpiler in `packages/db/src/migrations-utils/1736974607752_Migration/counter-transpiler/counters/`, one file per legacy name.

</details>


# Members

Count members, you can also filter them by their online status, specific roles and more.

This counter shows how many members your server has. You can filter which members are counted.

**Availability:** every bot can count all members, **Online + Idle + DND**, **Online**, **Offline** and banned members. The **Idle** and **Do not disturb** statuses and the account type, role, game and voice channel filters need the privileged intents, so they only work on a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting). The [legacy premium bot](/custom-bot/legacy-premium-vs-custom-bot) has no privileged intents and behaves like the public bot here. If you self-host, keep the [configuration](/self-hosting/configuration) defaults and turn the intents on in the [Developer Portal](/custom-bot/creating-a-discord-bot-account).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-db32f81371d300441db3461002ebf06b1d90c318%2Fcounter-members-options.png?alt=media" alt="Members options"><figcaption><p>Every option of the Members counter</p></figcaption></figure>

## Options

A member is counted only when it matches every filter you set. Leave a filter empty to skip it.

| Option                                     | What it does                                                                   | Notes                                                                                                                                                                              |
| ------------------------------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Count only banned members**              | Returns the number of banned users instead of members.                         | Off by default. It overrides the other options, and the dashboard hides them while it is checked. The bot needs the **Ban Members** permission.                                    |
| **Filter by status**                       | Counts only members whose presence matches the status you pick.                | **Any** (default), **Online + Idle + DND**, **Online**, **Idle**, **Do not disturb**, **Offline**.                                                                                 |
| **Filter by account type**                 | Counts only real users, only bots, or both.                                    | **Any** (default), **User**, **Bot**.                                                                                                                                              |
| **Filter by role**                         | Counts only members that have the roles you add.                               |                                                                                                                                                                                    |
| **Filter mode**                            | Decides how several selected roles are combined.                               | It sits in the **Filter by role** section. **Any match** (default) counts a member that has at least one of the roles, **Overlap** counts it only when it has every selected role. |
| **Filter by playing a game**               | Counts only members running an activity whose name contains the text you type. | Matching ignores case and looks at any activity, so `mine` also matches `Minecraft`, and a Spotify session or a custom status can match too.                                       |
| **Filter by connected to a voice channel** | Counts only members connected to one of the channels you add.                  | The picker lists voice and stage channels only.                                                                                                                                    |

Role, game and voice channel entries can be [nested counters](/using-the-dashboard/nested-counters). The gear button of the entry says what the nested counter has to return, for example a valid role ID.

## What it returns

A number. With **Count only banned members** checked it is the number of bans in the server, otherwise the number of members left after your filters.

The public bot and the legacy premium bot only get Discord's approximate counts, so **Online** and **Online + Idle + DND** return the same value there, and **Offline** returns the member count minus that value. Discord's number already includes idle and Do Not Disturb members.

## Formatting

Custom digits only reach text and announcement channel **topics**; voice and category channel **names** always show plain digits. See [Format settings](/using-the-dashboard/customizing-counters).

## Refresh and caching

Channels are re-rendered every **10 minutes** on the public bot and every **5 minutes** on the other bots. On those two bots the approximate counts are refreshed every **20 minutes** on top of that, so the number can be up to half an hour behind. The ban list is fetched once per server and kept up to date by Discord's ban events from then on, so new bans and unbans show up on the next update. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

The channel [/setup server](/commands/setup) creates is `Online: {counter}` with **Filter by status** set to **Online**.

{% tabs %}
{% tab title="Members with a role" %}
**Filter by role** = your `Staff` role, **Filter mode** = **Any match**. To count people who hold two roles at once, add both roles and switch to **Overlap**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-72adc6a7079f1997a958cc4805b4437e3115e6d2%2Fcounter-members-example-roles.png?alt=media" alt="Members counter filtered by role"><figcaption><p>Counting the members of one role</p></figcaption></figure>
{% endtab %}

{% tab title="Playing a game" %}
**Filter by playing a game** = `Minecraft`. Add more entries to count several games in one number.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e8210c49c0c2b6caeb737d634e9e73630763e792%2Fcounter-members-example-game.png?alt=media" alt="Members counter filtered by game"><figcaption><p>Counting who is playing a game right now</p></figcaption></figure>
{% endtab %}

{% tab title="Banned members" %}
Check **Count only banned members**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e6b69ff5f434c38920f176e428b5d3f1623eb88a%2Fcounter-members-example-banned.png?alt=media" alt="Members counter counting bans"><figcaption><p>With "Count only banned members" checked the other filters are hidden</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Errors

The channel shows `ERROR!` and the channel page names the reason.

| Message                                                                              | What to do                                                                                                                                                                                                                                        |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The bot is not a premium version and lacks certain features needed for this counter. | You used a filter the public bot cannot do. Move the counter to a [custom bot](/custom-bot/how-to-get-a-custom-bot) or a [self-hosted bot](/self-hosting/self-hosting), or switch to **Any**, **Online + Idle + DND**, **Online** or **Offline**. |
| The bot does not have enough privileged intents to use this counter.                 | On the legacy premium bot this is expected: it has no privileged intents, so use a custom bot or self-host. On a self-hosted bot, restore the [configuration](/self-hosting/configuration) defaults and restart the bot.                          |
| The member counts are not available at this moment.                                  | Discord did not send the approximate counts this time. It usually fixes itself on the next update.                                                                                                                                                |
| The result for the channel name is less than 2 characters long.                      | The finished voice or category channel name is a single character, for example a lone `0`. Add some text around the counter.                                                                                                                      |

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/members.ts`.
* The counting path is picked from `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` alone, but the `GuildMembers` and `GuildPresences` intents and the member and presence caches need `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` and `DISCORD_BOT_INSTANCE_IS_PREMIUM` both set to `true`, their `.env.example` defaults. **Filter by connected to a voice channel** also needs the `GuildVoiceStates` intent and the voice state cache, which exist only when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`.
* Privileged path: it filters a clone of `guild.members.cache`. **Overlap** keeps a member only when it has every selected role, **Any match** when it has at least one. The game filter trims and lowercases each entry and matches it with `includes` against the name of every activity in the member's presence.
* Unprivileged path: `guild.approximateMemberCount`, `guild.approximatePresenceCount` for **Online** and **Online + Idle + DND**, the difference of the two for **Offline**. Every other filter throws.
* A privileged and premium instance fetches the whole member list with presences on `guildAvailable`, so on every start and every reconnection, and follows gateway events from then on. A server joined while the bot is running is populated from the gateway payload and the events that follow.
* A non-privileged instance refreshes the approximate counts with the `Fetch approximate member count` job, cron `0 */20 * * * *`, plus once on client ready and once per server on `guildCreate`. The job is disabled when the instance is privileged.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`.
* Banned mode fetches the ban list once per server per bot process, remembers that server until the process restarts, and returns the size of the ban cache. That fetch is a REST call, gated by the **Ban Members** permission and not by any intent. Afterwards the ban cache is kept up to date from the `GUILD_BAN_ADD` and `GUILD_BAN_REMOVE` events, which arrive through the always-on `GuildModeration` intent, and it is uncapped.
* An unsupported filter throws `BOT_HAS_NO_ENOUGH_PRIVILEGED_INTENTS` on a premium instance and `BOT_IS_NOT_PREMIUM` on a non-premium one. A missing approximate count throws `MEMBER_COUNT_NOT_AVAILABLE`.
* A ban list fetch that fails for a missing **Ban Members** permission is not a known error, so the dashboard reports **An unknown error occurred.** instead of naming the permission.
* Each `{...}` part of the template has 60 seconds to return, nested counters included, then `EVALUATION_TIMEOUT`. The finished text is cut to 99 characters for channel names and to 1023 for topics, and a name shorter than 2 characters throws `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`.

</details>


# Roles

Count all roles present in the server.

This counter shows how many roles the server has. It takes no options.

**Availability:** every bot. It needs no privileged intents and no paid plan, so the only difference between bots is how often the channel is refreshed.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-67b3b237a2b2a14157132db2e79828517d6084c5%2Fcounter-roles-options.png?alt=media" alt="Roles counter panel"><figcaption><p>The Roles counter panel</p></figcaption></figure>

## What it returns

A number: every role in the server.

{% hint style="warning" %}
The total includes the `@everyone` role and every role managed by a bot or an integration, so it is normally higher than the number of roles you created by hand.
{% endhint %}

The counter cannot skip `@everyone` or bot roles on its own. To correct the number, wrap it in the [Math](/counters-reference/math) counter and subtract the roles you do not want to count.

## Formatting

Custom digits only reach text and announcement channel **topics**; voice and category channel **names** always show plain digits. See [Format settings](/using-the-dashboard/customizing-counters).

## Refresh and caching

Roles are kept up to date by Discord events, so the counter is accurate as of the last channel update: every **10 minutes** on the public bot and every **5 minutes** on the other bots. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Plain total" %}
Template: `Roles: {counter}`. This is the channel [/setup server](/commands/setup) creates for you.
{% endtab %}

{% tab title="Without @everyone" %}
Put the Roles counter inside a [Math](/counters-reference/math) counter with **Operation type** set to **Subtraction**, then add `1` to the **Number list** to drop `@everyone` from the total. Subtract more if bot roles should not count either.

Order matters: every following entry is subtracted from the first one, so the Roles counter has to come first in the **Number list** and `1` second.
{% endtab %}
{% endtabs %}

## Errors

The only message this counter can produce comes from the channel name rule:

| Message                                                         | What to do                                                                                                                                                                                                                       |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The result for the channel name is less than 2 characters long. | The finished voice or category channel name is a single character, which happens on a server with fewer than 10 roles and nothing but the counter in the template. Add some text around the counter, such as `Roles: {counter}`. |

The channel shows `ERROR!` while this is the case, and the channel page shows the reason.

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/roles.ts`. It returns `guild.roles.cache.size` and nothing else: no filtering, no fetch, no cache of its own.
* The role cache comes from the `Guilds` intent and is kept current by gateway events, so no privileged intent and no premium flag are involved.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`. That variable is the only one that changes anything for this counter when self-hosting.
* The only error the counter can produce is `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`. It is thrown for voice and category channel names only; topics are not length-checked, they are cut to 1023 characters, while names are cut to 99.

</details>


# Channels

Count the number of channels within specified categories or overall.

This counter shows how many channels the server has. Left alone it counts the whole server; with **Filter by category** it counts only the channels inside the categories you pick.

**Availability:** every bot. It needs no privileged intents and no paid plan, so the only difference between bots is how often the channel is refreshed.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-bc55bc02b1de9e82426ee55a7c728835be0ff0a7%2Fcounter-channels-options.png?alt=media" alt="Channels options"><figcaption><p>The only option is "Filter by category"</p></figcaption></figure>

## Options

| Option                 | What it does                                               | Notes                                                                                                                                     |
| ---------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Filter by category** | Counts only the channels inside the categories you select. | Empty by default, which counts the whole server. The picker lists categories only. Several categories are counted together in one number. |

An entry can be a [nested counter](/using-the-dashboard/nested-counters), and it has to return a valid category ID.

## What it returns

A number.

* With **Filter by category** empty: every channel in the server except the categories themselves. Text, announcement, voice, stage, forum and media channels all count.
* With one or more categories selected: every channel inside those categories.

{% hint style="warning" %}
Threads are never counted, so a busy forum channel counts as one channel no matter how many posts it holds.
{% endhint %}

Channels the bot cannot see are not counted either. If a number looks too low, check the bot's **View Channel** permission on the private channels you expected to be included.

## Formatting

Custom digits only reach text and announcement channel **topics**; voice and category channel **names** always show plain digits. See [Format settings](/using-the-dashboard/customizing-counters).

## Refresh and caching

The channel list is kept up to date by Discord events, so the counter is accurate as of the last channel update: every **10 minutes** on the public bot and every **5 minutes** on the other bots. Creating or deleting a channel therefore changes the number on the next update, not right away. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Every channel" %}
Template: `Channels: {counter}` with **Filter by category** empty. This is the channel [/setup server](/commands/setup) creates for you, and it shows the total for the whole server.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-a481de1c407566ac712b00385ea63a2f588d430a%2Fcounter-channels-example-total.png?alt=media" alt="Channels counter with no filter"><figcaption><p>No category selected, so the whole server is counted: the test server has 11 channels</p></figcaption></figure>
{% endtab %}

{% tab title="One category" %}
Add your `Support` category under **Filter by category**. The counter then shows how many channels live in it, and the category itself is not part of the total.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-99cc3700c544111a8b6e151535e0ea2bb60a3f13%2Fcounter-channels-example-category.png?alt=media" alt="Channels counter filtered by category"><figcaption><p>One category selected under "Filter by category"</p></figcaption></figure>
{% endtab %}

{% tab title="Several categories" %}
Add as many categories as you like. Their channels are added up into a single number, so `Support` plus `Tickets` gives you one combined total.
{% endtab %}
{% endtabs %}

## Errors

| Message                                                         | What to do                                                                                                                                                  |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The result for the channel name is less than 2 characters long. | The finished voice or category channel name is a single character, for example a lone `9`. Add some text around the counter, such as `Channels: {counter}`. |

The channel shows `ERROR!` while this is the case, and the channel page names the reason.

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/channels.ts`. With no category it counts every cached channel that is not a category; with categories it counts the channels whose parent is one of the selected ids, whatever their type.
* An entry that resolves to an empty string matches every channel that sits outside a category, the categories themselves included, and the number jumps. Any other id that is not a real category matches no channel and adds nothing. Neither case raises an error.
* Threads never reach the channel cache, because the bot caps every thread cache to 0.
* The channel cache comes from the `Guilds` intent and is kept current by gateway events, so no privileged intent and no premium flag are involved.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`. That variable is the only one that changes anything for this counter when self-hosting.
* The only error the counter can produce is `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`, raised for voice and category channel names only; topics are not length-checked, they are cut to 1023 characters, while names are cut to 99.

</details>


# Nitro Boosters

Retrieve the count of Nitro boosters for the server.

This counter shows how many Nitro boosts the server currently has. It takes no options.

**Availability:** every bot. It needs no privileged intents and no paid plan, so the only difference between bots is how often the channel is refreshed.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-b92d22471679cafa8f2e9d65db0c097140cc215b%2Fcounter-nitro-boosters-options.png?alt=media" alt="Nitro Boosters counter panel"><figcaption><p>The Nitro Boosters counter panel</p></figcaption></figure>

## What it returns

A number: the boost count Discord reports for the server.

{% hint style="warning" %}
This is the number of **boosts**, not the number of people boosting. A member with two boosts adds 2 to the total, so the counter can be higher than the number of names in the server's booster list.
{% endhint %}

When Discord sends no value, the counter shows `0` instead of failing.

## Formatting

Custom digits only reach text and announcement channel **topics**; voice and category channel **names** always show plain digits. See [Format settings](/using-the-dashboard/customizing-counters).

## Refresh and caching

The boost count comes from the server data Discord pushes to the bot, so it is accurate as of the last channel update: every **10 minutes** on the public bot and every **5 minutes** on the other bots. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Plain total" %}
Template: `Boosters: {counter}`. This is the channel [/setup server](/commands/setup) creates for you.
{% endtab %}

{% tab title="Boosts per member" %}
Combine it with the [Members](/counters-reference/members) counter inside a [Math](/counters-reference/math) counter to show how many members there are per boost.

{% hint style="warning" %}
This only works while the server has at least one boost. With zero boosts the division has a zero on the right-hand side, and the channel then shows `∞` (or `Infinity` with compact notation off) instead of a number.
{% endhint %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-b206c229fceb64bf887a32ba9dfc88e878a5c418%2Fcounter-nitro-boosters-example-ratio.png?alt=media" alt="Nitro Boosters counter inside a Math counter"><figcaption><p>A Math counter dividing Members by Nitro Boosters</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Errors

The only message this counter can produce comes from the channel name rule:

| Message                                                         | What to do                                                                                                                                                                             |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The result for the channel name is less than 2 characters long. | The finished voice or category channel name is a single character, for example a lone `0` on a server with no boosts. Add some text around the counter, such as `Boosters: {counter}`. |

The channel shows `ERROR!` while this is the case, and the channel page shows the reason.

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/nitroBoosters.ts`. It returns `guild.premiumSubscriptionCount ?? 0`, Discord's `premium_subscription_count` field, which is why a missing value comes out as `0` instead of an error.
* That field arrives with the guild data of the `Guilds` intent and is updated by gateway guild events, so the counter makes no API call and has no cache of its own. No privileged intent and no premium flag are involved.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`. That variable is the only one that changes anything for this counter when self-hosting.
* The only error the counter can produce is `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`. It is thrown for voice and category channel names only; topics are not length-checked, they are cut to 1023 characters, while names are cut to 99.

</details>


# Clock

Display a clock adjusted to your preferred timezone.

Shows the current time of the timezone you pick. Write the format yourself, for example `%H:%M` for `19:35`.

**Availability:** every bot, no API key. Only the update cadence differs: every 10 minutes on the public bot, every 5 minutes on a [legacy premium](/custom-bot/legacy-premium-vs-custom-bot), [custom](/custom-bot/custom-bot) or [self-hosted](/self-hosting/self-hosting) bot. See [Configuration](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-82a23c99ae0cb592f5c12f2e68c395e24f6110a5%2Fcounter-clock-options.png?alt=media" alt="Clock options"><figcaption><p>The two options of the Clock counter, with the <strong>Live preview</strong> underneath</p></figcaption></figure>

## Options

| Option            | What it does                                                          | Notes                                                                                                                                                                                                                                                                         |
| ----------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Timezone**      | The timezone the time is read in.                                     | Pick one of the presets, which you can search. They carry friendly labels, such as **Madrid** for `Europe/Madrid`. You cannot type one that is missing: use a [nested counter](/using-the-dashboard/nested-counters) that returns an identifier such as `Asia/Tokyo` instead. |
| **Format**        | The text the counter prints, with the placeholders below substituted. | Free text. The default is `%f`. Also takes a nested counter that returns a formatting string.                                                                                                                                                                                 |
| **Display label** | Renames the counter's chip in the editor.                             | Cosmetic. See [Formatting counters](/using-the-dashboard/customizing-counters#display-label).                                                                                                                                                                                 |

{% hint style="warning" %}
**Leave Timezone empty and you get UTC.**
{% endhint %}

### Placeholders

| Placeholder | What it does                              | Example   |
| ----------- | ----------------------------------------- | --------- |
| `%H`        | Hour with leading zero, 24-hour (00-23)   | `19`      |
| `%h`        | Hour without leading zero, 24-hour (0-23) | `19`      |
| `%I`        | Hour with leading zero, 12-hour (01-12)   | `07`      |
| `%i`        | Hour without leading zero, 12-hour (1-12) | `7`       |
| `%M`        | Minute with leading zero (00-59)          | `35`      |
| `%m`        | Minute without leading zero (0-59)        | `35`      |
| `%S`        | Second with leading zero (00-59)          | `00`      |
| `%s`        | Second without leading zero (0-59)        | `0`       |
| `%p`        | am/pm (lowercase)                         | `pm`      |
| `%P`        | AM/PM (uppercase)                         | `PM`      |
| `%f`        | Default locale-based formatting           | `7:35 PM` |

The table shows 19:35 with the `en-US` locale. `%f` prints the hour and the minute, nothing else. **Format** prints the rest as you type it, and an empty one falls back to `%f`. The placeholders are case-sensitive, and [Date](/counters-reference/date) and [Countdown](/counters-reference/countdown) have their own sets.

## What it returns

Text, never a number, even when the format is only digits. A voice or category channel name still has to end up at least 2 characters long; see [Channel settings](/using-the-dashboard/channel-settings).

## Formatting

Only **Override default locale** matters here: it decides what `%f` looks like, how `%p` and `%P` are worded, and which digits the time is written in. Without an override the counter follows [Server settings](/using-the-dashboard/server-settings). A locale with non-Latin digits breaks the four placeholders without a leading zero: `ar-EG` writes `%H` as `١٩`, and `%h`, `%i`, `%m` and `%s` print `NaN`, so use `%H`, `%I`, `%M` and `%S` with such a locale. See [Formatting counters](/using-the-dashboard/customizing-counters#override-default-locale).

## Refresh and caching

The time is rounded to the nearest 5 minutes, so `%S` always prints `00` and at 19:33 the clock already reads `19:35`. The channel changes only on the update cycle, so on the public bot `19:30` can sit there until 19:40. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="24-hour clock" %}
**Timezone** `Europe/Madrid`, **Format** `🕒 %H:%M` gives `🕒 19:35`. For a 12-hour clock, `%i:%M %P` gives `7:35 PM`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-dce2a0b8d6a1a25c107d568c71c0f63ed3d542c7%2Fcounter-clock-example-24h.png?alt=media" alt="A voice channel showing a 24-hour clock"><figcaption><p>A 24-hour clock in a voice channel name</p></figcaption></figure>
{% endtab %}

{% tab title="Two counters in one template" %}
A counter has one **Timezone**, so put two Clock counters in one template: `Madrid {counter} | NY {counter}`, set to `Europe/Madrid` and `America/New_York`, both with **Format** `%H:%M`. Output: `Madrid 19:35 | NY 13:35`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8bfde2e3cff7291368b8140ef96849470d75667e%2Fcounter-clock-example-two-timezones.png?alt=media" alt="A template with two Clock counters"><figcaption><p>Two Clock counters, one per timezone, in one template</p></figcaption></figure>

A [Date](/counters-reference/date) counter can sit in the same template, with its own **Timezone**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-d0e32fc9e1a471f7541daa30e23346490cc84fee%2Fcounter-clock-example-with-date.png?alt=media" alt="A Date counter and a Clock counter in one template"><figcaption><p>A Date counter and a Clock counter in one template, with the rendered preview underneath</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Errors

Clock has no fallback. When it fails the bot renames the channel to **ERROR!**, translated into the server's language, and it does not put the old name back. More in [Troubleshooting](/using-the-dashboard/troubleshooting).

| Message                                                         | What to do                                                                                                                                     |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **ERROR!** in the channel name                                  | Check the **Timezone** value and the **Override default locale** tag. The dashboard only reports **An unknown error occurred.** for these two. |
| The result for the channel name is less than 2 characters long. | Put some text around the counter.                                                                                                              |

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/clock.ts`. Nothing is cached; the time is recomputed on every template evaluation.
* The rounding is `Math.round(Date.now() / coeff) * coeff` with `coeff = 1000 * 60 * 5`, so the rendered time can be up to 2 minutes 30 seconds ahead of real time.
* `%f` is `Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", timeZone })`. `%H`, `%M` and `%S` come from `formatToParts` with `hour12: false`; `%I`, `%p` and `%P` from a second formatter with `hour12: true`.
* `%h`, `%i`, `%m` and `%s` are `parseInt()` of the padded part, which is why a locale with non-Latin digits turns them into `NaN`.
* With no **Timezone** picked, `timeZone` is `undefined` and `Intl` uses the host's timezone. The official containers run in UTC.
* Locale resolution: the counter's override, else the server's, and `en-US` when the tag is shorter than 2 characters.
* An invalid timezone identifier or locale tag makes `Intl.DateTimeFormat` throw a `RangeError`. It is not a `KnownError`, so the dashboard falls back to **An unknown error occurred.**
* Each top-level counter in a template has a 60 second timeout (`EVALUATION_TIMEOUT`), covering its nested counters too. The result is cut to 99 characters for voice and category names, 1023 for text and announcement topics, and a name under 2 characters throws `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`.
* On a failed evaluation the bot writes `jobs.updateChannels.computeError` (**ERROR!**) into the channel and stores the message as the channel's `LastTemplateComputeError`.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`. The shipped `.env.example` sets it to `true`. No other environment variable affects Clock.
* The timezone list is `apps/frontend/src/lib/timezones.ts` (109 entries); the combobox only accepts entries from it.
* There is no live preview when **Format** is empty or when a counter fills it; the whole template has to be previewed instead.

</details>


# Date

Display the current date adjusted to your preferred timezone.

Shows today's date in the timezone you pick. Write the format yourself, for example `%D/%M/%Y` for `19/09/2026`.

**Availability:** every bot, no API key. Only the update cadence differs: every 10 minutes on the public bot, every 5 minutes on a [legacy premium](/custom-bot/legacy-premium-vs-custom-bot), [custom](/custom-bot/custom-bot) or [self-hosted](/self-hosting/self-hosting) bot. See [Configuration](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3911c8e7b68e46bd0ee88b43dc56b90b21fadc13%2Fcounter-date-options.png?alt=media" alt="Date options"><figcaption><p>The two options of the Date counter, with the <strong>Live preview</strong> underneath</p></figcaption></figure>

## Options

| Option            | What it does                                                          | Notes                                                                                                                                                                                                                                                                                                |
| ----------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Timezone**      | The timezone the date is read in.                                     | The same presets the [Clock](/counters-reference/clock) counter uses. They carry friendly labels, such as **Madrid** for `Europe/Madrid`. You cannot type one that is missing: use a [nested counter](/using-the-dashboard/nested-counters) that returns an identifier such as `Asia/Tokyo` instead. |
| **Format**        | The text the counter prints, with the placeholders below substituted. | Free text. The default is `%f`. Also takes a nested counter that returns a formatting string.                                                                                                                                                                                                        |
| **Display label** | Renames the counter's chip in the editor.                             | Cosmetic. See [Formatting counters](/using-the-dashboard/customizing-counters#display-label).                                                                                                                                                                                                        |

{% hint style="warning" %}
**Leave Timezone empty and you get UTC**, so the date can flip a few hours early.
{% endhint %}

### Placeholders

| Placeholder | What it does                              | Example              |
| ----------- | ----------------------------------------- | -------------------- |
| `%Y`        | Full year                                 | `2026`               |
| `%y`        | 2-digit year                              | `26`                 |
| `%Ms`       | Full month name                           | `September`          |
| `%ms`       | Short month name                          | `Sep`                |
| `%M`        | Month number with leading zero (01-12)    | `09`                 |
| `%m`        | Month number (1-12)                       | `9`                  |
| `%Ws`       | Full day of week name                     | `Saturday`           |
| `%ws`       | Short day of week name                    | `Sat`                |
| `%w`        | Day of week number (0=Sunday, 6=Saturday) | `6`                  |
| `%D`        | Day with leading zero                     | `19`                 |
| `%d`        | Day without leading zero                  | `19`                 |
| `%f`        | Default locale-based formatting           | `September 19, 2026` |

The table shows Saturday 19 September 2026 with the `en-US` locale. `%f` never includes the time, so use a [Clock](/counters-reference/clock) counter for that. **Format** prints the rest as you type it, and an empty one falls back to `%f`. The placeholders are case-sensitive: `%Ms` is `September`, `%ms` is `Sep`. [Countdown](/counters-reference/countdown) has its own set.

## What it returns

Text, never a number, even when the format is only digits and slashes. A voice or category channel name still has to end up at least 2 characters long; see [Channel settings](/using-the-dashboard/channel-settings).

## Formatting

Only **Override default locale** matters here. It decides the language of `%f`, `%Ms`, `%ms`, `%Ws` and `%ws`, and which digits `%Y`, `%y`, `%M` and `%D` are written in. Names follow the locale, not the timezone. Without an override the counter follows [Server settings](/using-the-dashboard/server-settings), where the locale changes every counter in the server at once. A locale with non-Latin digits breaks `%m` and `%d`: `ar-EG` writes `%Y` as `٢٠٢٦` and `%D` as `١٩`, while the forms without a leading zero print the literal text `NaN`. See [Formatting counters](/using-the-dashboard/customizing-counters#override-default-locale).

## Refresh and caching

Just before midnight the date can already show tomorrow. The channel changes on the update cycle, so the new date lands at the first cycle after midnight. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Weekday and month" %}
**Timezone** `Europe/Madrid`, **Format** `%Ws, %Ms %d` gives `Saturday, September 19`. For a short voice channel name, `📅 %ws %d %ms` gives `📅 Sat 19 Sep`. For a numeric date, `%D/%M/%Y` gives `19/09/2026` and `%M/%D/%Y` gives `09/19/2026`; swap `%D` for `%d` and `%M` for `%m` to drop the leading zeros. With **Override default locale** set to `es-ES`, `%f` gives `19 de septiembre de 2026`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-cfc856f091a2f91f0e4d3a9723e68008adfa5212%2Fcounter-date-example-weekday.png?alt=media" alt="A voice channel showing the weekday and the date"><figcaption><p>The weekday and the date in a voice channel name</p></figcaption></figure>
{% endtab %}

{% tab title="Date and time together" %}
Combine with a [Clock](/counters-reference/clock) counter: `📅 {Date} · 🕒 {Clock}`, the Date counter with **Format** `%d %ms` and the Clock counter with `%H:%M`, gives `📅 19 Sep · 🕒 20:00`. Set the timezone on both; they do not share one.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-9f95dddb1ea08cd77d5de1ccd1f2b8eaf4af8025%2Fcounter-date-example-with-clock.png?alt=media" alt="A Date counter and a Clock counter in one template"><figcaption><p>A Date counter and a Clock counter side by side</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Errors

Date has no fallback. When it fails the bot renames the channel to **ERROR!**, translated into the server's language, and it does not put the old name back. More in [Troubleshooting](/using-the-dashboard/troubleshooting).

| Message                                                         | What to do                                                                                                                                     |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **ERROR!** in the channel name                                  | Check the **Timezone** value and the **Override default locale** tag. The dashboard only reports **An unknown error occurred.** for these two. |
| The result for the channel name is less than 2 characters long. | Put some text around the counter.                                                                                                              |

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/date.ts`. Nothing is cached; the date is recomputed on every template evaluation.
* It rounds with `Math.round(Date.now() / coeff) * coeff` and `coeff = 1000 * 60 * 5`, exactly like [Clock](/counters-reference/clock), so the date flips up to 2 minutes 30 seconds before midnight in the chosen timezone.
* `%f` is `Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric", timeZone })`. `%Y`, `%M` and `%D` come from `formatToParts` with `year: "numeric", month: "2-digit", day: "2-digit"`, and `%y` is the last two characters of `%Y`.
* `%Ms`, `%ms`, `%Ws` and `%ws` are separate `Intl.DateTimeFormat` calls with `month: "long" / "short"` and `weekday: "long" / "short"`. `%w` is `new Date(rounded.toLocaleString("en-US", { timeZone })).getDay()`.
* Substitution runs `%f`, `%Y`, `%y`, `%Ms`, `%ms`, `%M`, `%m`, `%Ws`, `%ws`, `%w`, `%D`, `%d`, so each two-letter placeholder is consumed before its one-letter form.
* `%m` and `%d` are `parseInt()` of the padded part, which is why a locale with non-Latin digits turns them into `NaN`.
* With no **Timezone** picked, `timeZone` is `undefined` and `Intl` uses the host's timezone. The official containers run in UTC.
* Locale resolution: the counter's override, else the server's, and `en-US` when the tag is shorter than 2 characters.
* An invalid timezone identifier or locale tag makes `Intl.DateTimeFormat` throw a `RangeError`. It is not a `KnownError`, so the dashboard falls back to **An unknown error occurred.**
* Each top-level counter in a template has a 60 second timeout (`EVALUATION_TIMEOUT`), covering its nested counters too. The result is cut to 99 characters for voice and category names, 1023 for text and announcement topics, and a name under 2 characters throws `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`.
* On a failed evaluation the channel update job writes `jobs.updateChannels.computeError` (**ERROR!**) into the channel and stores the message as the channel's `LastTemplateComputeError`.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`. The shipped `.env.example` sets it to `true`. No other environment variable affects Date.
* The timezone list is `apps/frontend/src/lib/timezones.ts` (109 entries), shared with Clock; the combobox only accepts entries from it.
* There is no **Live preview** when **Format** is empty or is filled by a nested counter; the whole template has to be previewed instead.

</details>


# Countdown

Create a countdown timer with a format tailored to your needs.

Shows how much time is left until a date you set. Write the format yourself, for example `%d days left`.

**Availability:** every bot, no API key. Only the update cadence differs: every 10 minutes on the public bot, every 5 minutes on a [legacy premium](/custom-bot/legacy-premium-vs-custom-bot), [custom](/custom-bot/custom-bot) or [self-hosted](/self-hosting/self-hosting) bot. See [Configuration](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8e227f45760314b8ae7230448c85cdb08dfd1f38%2Fcounter-countdown-options.png?alt=media" alt="Countdown options"><figcaption><p>The two options of the Countdown counter, with the <strong>Live preview</strong> underneath</p></figcaption></figure>

## Options

| Option            | What it does                                                          | Notes                                                                                                                                                                                                                                                                                                                          |
| ----------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Target date**   | The moment the countdown runs to.                                     | A date-and-time field, or a [nested counter](/using-the-dashboard/nested-counters) picked in the box underneath. A new counter starts one hour ahead. There is no timezone option: the field is read in your browser's timezone and stored as one absolute moment, so everybody in the server counts down to the same instant. |
| **Format**        | The text the counter prints, with the placeholders below substituted. | Free text. The default is `%d days, %h hours and %m minutes left`. Also takes a nested counter that returns a formatting string.                                                                                                                                                                                               |
| **Display label** | Renames the counter's chip in the editor.                             | Cosmetic. See [Formatting counters](/using-the-dashboard/customizing-counters#display-label).                                                                                                                                                                                                                                  |

{% hint style="warning" %}
A counter feeding **Target date** must return a UNIX timestamp in **milliseconds**. A seconds-based timestamp resolves to 1970, so the countdown shows all zeros; multiply it by 1000 with a [Math](/counters-reference/math) counter first.
{% endhint %}

### Placeholders

| Placeholder | What it does        | Example   |
| ----------- | ------------------- | --------- |
| `%d`        | Days left           | `12`      |
| `%h`        | Hours left (0-23)   | `3`       |
| `%m`        | Minutes left (0-59) | `25`      |
| `%s`        | Seconds left (0-59) | `0`       |
| `%D`        | Total days left     | `12`      |
| `%H`        | Total hours left    | `291`     |
| `%M`        | Total minutes left  | `17485`   |
| `%S`        | Total seconds left  | `1049100` |

The table shows one example duration. The lowercase placeholders are the parts of one duration; the uppercase ones give the whole time in a single unit. **Format** prints the rest as you type it, with no zero padding: three hours prints as `3`, never `03`. Removing **Format** with its **X** button does not put the default back: a counter with no format prints `%d:%h:%m`, so `12:3:25`. Type the default in again if you want it.

## What it returns

Text: your **Format** string with every placeholder replaced by a plain whole number. Once the target date has passed the counter holds at `0 days, 0 hours and 0 minutes left`. It does not count up and it does not remove itself, so change the date or delete the counter when the event is over. A voice or category channel name still has to end up at least 2 characters long; see [Channel settings](/using-the-dashboard/channel-settings).

## Formatting

**Format settings** does nothing here: the counter returns plain digits, whatever the locale. To write it in another language, type the words into **Format**, for example `quedan %d días`.

## Refresh and caching

Nothing is cached, so the countdown uses the exact current time. What limits it is the update cycle: the value jumps in 5- or 10-minute steps, which is why `%s` and `%S` are not much use in a channel. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Picking a format" %}
**Target date** 1 October 2026 at 18:00 with the default **Format** gives `12 days, 3 hours and 25 minutes left`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-1590da0d49de808749a7fbf1e1e4fd289ad50079%2Fcounter-countdown-example-default.png?alt=media" alt="A channel topic with the default countdown format"><figcaption><p>The default format in a text channel topic</p></figcaption></figure>

Delete the placeholders and words you do not want. `🎉 Event in %d days` gives `🎉 Event in 12 days`: short enough for a voice channel name, and it only changes once a day. For a single unit in full, `%H hours to launch` gives `291 hours to launch`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-42e91d2d6194ba40339ceb316895f9f0e288b5ae%2Fcounter-countdown-example-days-only.png?alt=media" alt="A voice channel counting down in days"><figcaption><p>A days-only countdown in a voice channel name</p></figcaption></figure>
{% endtab %}

{% tab title="Date from another counter" %}
Pick a counter in the box under **Target date** instead of typing a date. An [HTTP](/counters-reference/http) counter that returns the next event time keeps the countdown correct without anyone editing the template. If your endpoint returns seconds, put a [Math](/counters-reference/math) counter in between and multiply by `1000`. See [Nested counters](/using-the-dashboard/nested-counters).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-d689cab25dbb3a90942dd284bae3c37467706b55%2Fcounter-countdown-example-nested-date.png?alt=media" alt="A Countdown counter taking its date from another counter"><figcaption><p>The date comes from a nested counter, so the date field is replaced</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Errors

Countdown has no fallback. A missing target date, or one that has passed, counts down to zero and stays there rather than failing. More in [Troubleshooting](/using-the-dashboard/troubleshooting).

| Message                                                         | What to do                                                                                                                                                                                                         |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `NaN days, NaN hours and NaN minutes left`                      | The counter in **Target date** returned something other than a number, such as an ISO date or an empty response. Feed it milliseconds and nothing else.                                                            |
| The result for the channel name is less than 2 characters long. | Put some text around the counter.                                                                                                                                                                                  |
| **ERROR!** in the channel name                                  | A nested counter failed outright, for example an [HTTP](/counters-reference/http) counter pointed at a dead URL. The channel page names the reason under **The template had an error when it was last processed**. |

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/countdown.ts`. It computes `new Date(date - Date.now())` and replaces it with `new Date(0)` once the target has passed, which is what holds every placeholder at zero.
* Unlike [Clock](/counters-reference/clock) and [Date](/counters-reference/date) it does not round the instant: it reads `Date.now()` straight.
* Every placeholder is a whole number, rounded down. `%d` and `%D` are the same value by design: the days left. `%h`, `%m` and `%s` are the hours, minutes and seconds left after the larger units are taken out; `%H`, `%M` and `%S` are the whole remainder in hours, minutes and seconds.
* The numbers are interpolated as plain strings, never through `Intl.NumberFormat`, so the locale, **Use compact notation** and **Override default digits** have nothing to act on.
* `options.date` defaults to `0` when absent, which is 1 January 1970, an already-expired countdown. A nested counter that returns anything but a number makes `date - Date.now()` evaluate to `NaN`, and every placeholder prints `NaN`.
* The stored value is a millisecond epoch timestamp. The date-and-time field is converted with the browser's offset *now*, not the one in force on the target date, so a target across a daylight-saving change lands an hour away from what was typed. The chip shows the stored moment converted back, so compare the two.
* The editor writes the default format into the counter on the first change made in the panel. A counter carrying no format at all, such as one migrated from the old bot, hits the evaluator's `options.format ?? "%d:%h:%m"`.
* Each top-level counter in a template has a 60 second timeout (`EVALUATION_TIMEOUT`), covering its nested counters too. The result is cut to 99 characters for voice and category names, 1023 for text and announcement topics, and a name under 2 characters throws `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`.
* On a failed evaluation the update job writes **ERROR!** into the channel and stores the message as the channel's `LastTemplateComputeError`.
* The `Update channels` job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true`, and on `0 */10 * * * *` when it is `false`. The shipped `.env.example` sets it to `true`. No other environment variable affects Countdown.
* There is no live preview while **Target date** or **Format** is empty. There is none either while one of them is filled by a counter, and then the whole template has to be previewed instead.

</details>


# Twitch

Retrieve the follower count, live viewer count or channel name of a Twitch channel.

The Twitch counter follows one Twitch channel and returns its follower count, how many people are watching right now, or the channel's display name.

**Availability:** a [custom bot](/custom-bot/custom-bot), a [self-hosted bot](/self-hosting/self-hosting) or the legacy premium bot. It does **not** work on the free public bot. A self-hosted bot needs `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET` in its `.env`; see the [configuration reference](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-284a5e0e93b5c04bf84c9afc513e63c7c38970eb%2Fcounter-twitch-options.png?alt=media" alt="Twitch options"><figcaption><p>The Twitch counter options in the template editor</p></figcaption></figure>

## Options

| Option       | What it does                                                                    | Notes                                                                                                                                                           |
| ------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Display**  | Picks what the counter outputs: **Followers**, **Viewers** or **Channel name**. | Starts on **Followers**. Does not accept a nested counter.                                                                                                      |
| **Username** | The Twitch channel to follow.                                                   | Type the login name, the part after `twitch.tv/`. It also accepts a [nested counter](/using-the-dashboard/nested-counters), which must return a valid username. |

{% hint style="warning" %}
**Username** is the login name from the channel address, not the capitalised display name and not a full URL. For `twitch.tv/example` you type `example`.
{% endhint %}

## What it returns

| **Display**      | Value                                                                                                           |
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
| **Followers**    | How many followers the channel has.                                                                             |
| **Viewers**      | The viewer count while the stream is live, or the text `offline`, in lower case, when it is not.                |
| **Channel name** | The channel's display name, with the capitalisation the streamer chose. It can differ from the login you typed. |

## Formatting

**Followers** and a live **Viewers** count are numbers, so the **Format settings** panel applies. **Channel name** and `offline` are text and come back untouched. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

The bot keeps each channel's data for 15 minutes, so a stream that just went live can take up to 15 minutes to show up. More in [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Followers" %}
**Display** = **Followers**, **Username** = `example`, **Use compact notation** = **Yes**.

A channel with 1,240 followers shows as `1.2K`.
{% endtab %}

{% tab title="Live viewers" %}
**Display** = **Viewers**, **Username** = `example`.

While the stream is live the channel shows the viewer count, for example `Viewers: 342`. The rest of the time it reads `offline`. To use your own wording off air, nest the Twitch counter in a [Replace](/counters-reference/replace) counter and replace `offline`.
{% endtab %}
{% endtabs %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-dd67646fc49292dbe3686c4818b10eba85b99245%2Fcounter-twitch-example-offline.png?alt=media" alt="A voice channel showing a Twitch viewer count"><figcaption><p>The same counter live and off air</p></figcaption></figure>

## Errors

| Message                                                                                | What to do                                                                                                                       |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `The bot is not a premium version and lacks certain features needed for this counter.` | The counter is running on the free public bot. Use the legacy premium bot, a [custom bot](/custom-bot/custom-bot), or self-host. |
| `The Twitch counter requires a username to be set.`                                    | Fill in **Username**, or check that the nested counter in it returns a name.                                                     |
| `The specified Twitch channel could not be found.`                                     | No Twitch channel has that login name. Check the spelling against the channel address.                                           |

On a self-hosted bot with no `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`, every Twitch counter fails with a raw error instead. Get the credentials from [the Twitch developer console](https://dev.twitch.tv/console/apps) and set both variables. See [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/Twitch.ts`.
* The checks run in this order: `isPremium`, then **Username**, then the lookup. On the free public bot the premium error always comes first, even when the rest of the counter is empty.
* The API client is built once when the bot starts, from `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`. If either is missing the client is never created and every Twitch counter on that instance throws `"TWITCH_CLIENT_ID" or "TWITCH_CLIENT_SECRET" not provided`, which is not translated and reaches the dashboard as a generic failure.
* Cached in Redis for 15 minutes under the key `1:13:<username>`. The key holds no server id, so every server watching the same channel shares one fetch.
* Each lookup makes three Twitch API calls, and one cache entry holds the channel name, the follower count and the viewer count together, so the three **Display** values refresh at the same moment.
* Only successful lookups are cached. A failing counter retries on every channel update.
* The update job runs every 5 minutes on premium, custom and self-hosted bots, on its own clock, so in the worst case the text on the channel is about 20 minutes behind Twitch.
* An unset **Display** evaluates as **Followers**.
* `The bot is not a premium version and lacks certain features needed for this counter.` (`BOT_IS_NOT_PREMIUM`): the bot instance runs with `isPremium` false.
* `The Twitch counter requires a username to be set.` (`TWITCH_MISSING_USERNAME`): **Username** is empty, or the nested counter in it returned nothing.
* `The specified Twitch channel could not be found.` (`TWITCH_CHANNEL_NOT_FOUND`): the lookup found no channel with that login.
* `The evaluation took too long and timed out.` (`EVALUATION_TIMEOUT`): one counter in the template took more than 60 seconds.
* Self-hosting: `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`, plus `DISCORD_BOT_INSTANCE_IS_PREMIUM` left at its default `true`. Custom bots are started with that flag forced on.

</details>


# YouTube

Fetch subscriber count, video count, and total views from a YouTube channel.

The YouTube counter follows one YouTube channel and returns its subscriber count, its lifetime views, how many videos it has published, or its name.

**Availability:** a [custom bot](/custom-bot/custom-bot), a [self-hosted bot](/self-hosting/self-hosting) or the legacy premium bot. It does **not** work on the free public bot. A self-hosted bot needs `YOUTUBE_API_KEY` in its `.env`; see the [configuration reference](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-fe24cfb08b067c01e337cefa1609c67f7d275c57%2Fcounter-youtube-options.png?alt=media" alt="YouTube options"><figcaption><p>The YouTube counter options in the template editor</p></figcaption></figure>

## Options

| Option          | What it does                                                                                | Notes                                                                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Display**     | Picks what the counter outputs: **Subscribers**, **Views**, **Videos** or **Channel name**. | Starts on **Subscribers**. Does not accept a nested counter.                                                                                         |
| **Channel URL** | The channel to look up, written as an `@handle` or a YouTube address.                       | Only the shapes below are accepted. It also accepts a [nested counter](/using-the-dashboard/nested-counters), which must return a valid channel URL. |

### Accepted **Channel URL** shapes

`http://` or `https://` at the front is optional, and so is `www.` or `m.`.

| What you type               | How the bot finds the channel |
| --------------------------- | ----------------------------- |
| `@handle` on its own        | By handle. Prefer this shape. |
| `youtube.com/@handle`       | By handle.                    |
| `youtube.com/channel/UC...` | By channel ID.                |
| `youtube.com/user/name`     | By legacy username.           |
| `youtube.com/c/name`        | By legacy username.           |

Anything else is refused as invalid, including a bare channel ID, video links, `youtu.be` links and playlist links. A trailing `/videos`, `/about` or `?si=...` makes the lookup fail.

{% hint style="warning" %}
Most `youtube.com/c/name` addresses are looked up as legacy usernames and no longer resolve, so the counter fails. Use the `@handle` form.
{% endhint %}

## What it returns

| **Display**      | Value                                        |
| ---------------- | -------------------------------------------- |
| **Subscribers**  | The subscriber count, as YouTube reports it. |
| **Views**        | The lifetime view count of the channel.      |
| **Videos**       | How many videos the channel has published.   |
| **Channel name** | The name of the channel, as text.            |

## Formatting

**Subscribers**, **Views** and **Videos** are numbers, so the **Format settings** panel applies. **Channel name** is text and comes back exactly as YouTube spells it. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

The bot keeps each channel's data for 60 minutes, so a YouTube counter changes once an hour at most. More in [How counters update](/using-the-dashboard/how-counters-update).

## Examples

**Display** = **Subscribers**, **Channel URL** = `@example`, **Use compact notation** = **Yes**. A channel with 1,230,000 subscribers shows as `1.2M`.

Switch **Display** to **Views** on the same channel and it shows the views of every video added together.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-75fa0c8e0fdac9b85f6bbace5cbf7539dc0c808b%2Fcounter-youtube-example-subscribers.png?alt=media" alt="A voice channel showing a YouTube subscriber count"><figcaption><p>A voice channel driven by a YouTube counter</p></figcaption></figure>

## Errors

| Message                                                                                | What to do                                                                                                                       |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `The bot is not a premium version and lacks certain features needed for this counter.` | The counter is running on the free public bot. Use the legacy premium bot, a [custom bot](/custom-bot/custom-bot), or self-host. |
| `The YouTube counter requires a channel URL to be set.`                                | Fill in **Channel URL**, or check that the nested counter in it returns an address.                                              |
| `The provided YouTube channel URL is invalid.`                                         | What you typed is not one of the accepted shapes above.                                                                          |

An accepted address that YouTube cannot resolve fails with a generic error instead. On a self-hosted bot the same happens when `YOUTUBE_API_KEY` is missing. See [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/YouTube.ts`.
* The checks run in this order: `isPremium`, then `YOUTUBE_API_KEY`, then **Channel URL**, then its shape. On a self-hosted bot without a key you get the key error even when the real problem is an empty field.
* The shape decides the lookup parameter: `/user/` and `/c/` become the API's `forUsername`, a leading `@` and `youtube.com/@` become `forHandle`, `/channel/` becomes `id`.
* One request per lookup: `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet`, with a 5 second timeout. All four **Display** values come from it and refresh together. It spends YouTube Data API v3 quota, which is why self-hosters bring their own key.
* The values come from `statistics.subscriberCount`, `statistics.viewCount`, `statistics.videoCount` and `snippet.title`. YouTube rounds the subscriber count to three significant figures above 1,000, so 1,234,567 subscribers arrive as 1,230,000 and the counter can never be exact.
* When the request succeeds but returns no channel, an assertion throws with no message and the counter fails generically. Most `youtube.com/c/name` addresses end here, because `forUsername` only resolves true legacy usernames.
* Cached in Redis for 60 minutes under the key `1:15:<lookup parameter>:<value>`. The key holds how the channel was resolved, so `@handle` and `youtube.com/channel/UC...` for the same channel are two entries that can be an hour apart. It holds no server id, so every server watching the same channel shares one fetch.
* Only successful lookups are cached. A failing counter retries on every channel update, which runs every 5 minutes on premium, custom and self-hosted bots.
* An unset **Display** evaluates as **Subscribers**.
* `The bot is not a premium version and lacks certain features needed for this counter.` (`BOT_IS_NOT_PREMIUM`): the bot instance runs with `isPremium` false.
* `The YouTube counter requires a channel URL to be set.` (`YOUTUBE_MISSING_CHANNEL_URL`): **Channel URL** is empty, or the nested counter in it returned nothing.
* `The provided YouTube channel URL is invalid.` (`YOUTUBE_INVALID_CHANNEL_URL`): the address matched none of the three shapes.
* `YOUTUBE_API_KEY not provided`: a raw, untranslated error on a self-hosted bot with no key.
* A key that has run out of daily quota gets a 403 whose body fails the response validation, so the counter fails generically and no value is cached.
* `The evaluation took too long and timed out.` (`EVALUATION_TIMEOUT`): one counter in the template took more than 60 seconds.
* Self-hosting: `YOUTUBE_API_KEY` from the Google API console with the YouTube Data API v3 enabled, plus `DISCORD_BOT_INSTANCE_IS_PREMIUM` left at its default `true`. Custom bots are started with that flag forced on.

</details>


# Reddit

Retrieve the title of a subreddit along with the total number of members and the count of members currently online.

The Reddit counter reads one subreddit and returns its member count, how many members are online, or the subreddit title.

**Availability:** every bot, including the free public bot. It needs no API key and no extra configuration.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-b5de264c55bea10ae5c582188314cbf9a136daa7%2Fcounter-reddit-options.png?alt=media" alt="Reddit options"><figcaption><p>The Reddit counter options in the template editor</p></figcaption></figure>

## Options

| Option             | What it does                                                                            | Notes                                                                                                                                                          |
| ------------------ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Display**        | Picks what the counter outputs: **Members**, **Members online** or **Subreddit title**. | Starts on **Members**. Does not accept a nested counter.                                                                                                       |
| **Subreddit name** | The subreddit to look up.                                                               | Type the bare name, for example `memes`. It also accepts a [nested counter](/using-the-dashboard/nested-counters), which has to return a valid subreddit name. |

{% hint style="warning" %}
**Subreddit name** takes the bare name and nothing else. `memes` works; `r/memes` and `https://www.reddit.com/r/memes` do not.
{% endhint %}

## What it returns

| **Display**         | Value                                                                                   |
| ------------------- | --------------------------------------------------------------------------------------- |
| **Members**         | How many members the subreddit has.                                                     |
| **Members online**  | How many members Reddit reports as active right now.                                    |
| **Subreddit title** | The title of the subreddit, the line under its name on Reddit. It is not the `r/` name. |

## Formatting

**Members** and **Members online** are numbers, so the **Format settings** panel applies. **Subreddit title** is text, and formatting does nothing to it. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

The bot keeps each subreddit's data for 15 minutes, so a channel can show a value up to 15 minutes old. More in [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Members" %}
**Display** = **Members**, **Subreddit name** = `memes`, **Use compact notation** = **Yes**.

A subreddit with 1,200,000 members shows as `1.2M`.
{% endtab %}

{% tab title="Members online" %}
**Display** = **Members online**, **Subreddit name** = `AskReddit`.
{% endtab %}
{% endtabs %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-7069ae2ad7fa1d0b50696fea6d8d3ecbc3f69b07%2Fcounter-reddit-example-members.png?alt=media" alt="A voice channel showing a subreddit member count"><figcaption><p>A voice channel driven by a Reddit counter</p></figcaption></figure>

## Errors

| Message                                                | What to do                                                                         |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `A subreddit must be provided for the Reddit counter.` | Fill in **Subreddit name**, or check that the nested counter in it returns a name. |

A subreddit that is private, banned or misspelled fails with a generic error instead. Check the spelling first, then see [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/reddit.ts`.
* One `GET https://www.reddit.com/r/<subreddit>/about.json` per lookup, with a 5 second timeout. The value of **Subreddit name** is put straight into that address.
* The three values come from `data.subscribers`, `data.active_user_count` and `data.title` of that response. One request fills all three, so they always move together.
* Cached in Redis for 15 minutes under the key `1:9:<subreddit>`. The key holds no server id, so every server watching the same subreddit shares one fetch.
* Only successful lookups are cached. A failing counter retries on every channel update.
* The update job runs every 10 minutes on the public bot and every 5 minutes on every other bot, on its own clock. In the worst case the text on the channel is about 25 minutes behind Reddit, 20 minutes on a 5 minute bot.
* An unset **Display** evaluates as **Members**.
* `A subreddit must be provided for the Reddit counter.` (`REDDIT_MISSING_SUBREDDIT`): **Subreddit name** is empty, or the nested counter in it returned nothing.
* In Discord the same error reads `A subreddit must be provided for the Reddit coutner.`; the typo is in the bot's en-US string, the dashboard string is correct.
* A private, banned, quarantined or misspelled subreddit, and Reddit rate limiting the bot, make the response fail validation. There is no error name for that case, so the dashboard shows a generic failure and no value is cached.
* `The evaluation took too long and timed out.` (`EVALUATION_TIMEOUT`): one counter in the template took more than 60 seconds.
* No environment variables. The evaluator never checks `isPremium`, so the counter also runs on a self-hosted bot with an empty `.env`.

</details>


# Game

Retrieve the current number of online players in your game server, compatible with more than 320 games.

The Game counter queries your game server and shows how many players are online right now. It uses node-gamedig, so Minecraft, Rust, ARK and many other games work. The full list is the [node-gamedig game list](https://github.com/gamedig/node-gamedig/blob/master/GAMES_LIST.md).

**Availability:** every bot. It needs no privileged intents. A self-hosted bot must be able to reach the game server's query port outbound.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-347de3142fb8fd79713aa133892b1372088088ce%2Fcounter-game-options.png?alt=media" alt="Game counter options"><figcaption><p>The Game counter panel</p></figcaption></figure>

## Options

| Option                   | What it does                                                                                | Notes                                                                                                                                   |
| ------------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Game**                 | Which game the server runs, and therefore which query protocol the bot speaks.              | Required. Type in the box to search the list. This field only takes an entry from the list or a counter, never a game ID typed by hand. |
| **Address**              | The host of the game server: an IP such as `1.2.3.4` or a domain such as `mc.example.com`.  | Required. Do not add the port here, it has its own field.                                                                               |
| **Port (or query port)** | The port the query is sent to.                                                              | Required. Picking a game fills in a default port, but only while the field is still empty. Only numbers are accepted.                   |
| **Display label**        | A custom name for this counter's chip in the editor. It does not change the channel output. | Optional. Open it with the **Edit display label** button.                                                                               |

All three query options accept another counter instead of a typed value. The gear button on the nested counter's row tells you what it has to return. See [Nested counters](/using-the-dashboard/nested-counters).

{% hint style="info" %}
The **Game** list shows readable names such as `Minecraft` or `Ark: Survival Evolved`, but the value stored is the node-gamedig game ID: `minecraft`, `ase`. A counter used in this field has to return that ID, not the readable name.
{% endhint %}

## Query port or game port

This is the most common reason a Game counter does not work. Many games answer status queries on a different port from the one players connect to, and this field wants the **query** port. Minecraft uses the same port for both, `25565`. ARK does not.

Picking a game fills in that game's query port if node-gamedig knows one, and its connect port otherwise, so check the number. If you change the game later, the old port stays and you have to correct it yourself. When you are not sure, look up the query port in your host's control panel or in your server configuration.

## What it returns

The number of players online, and nothing else. Maximum players, the map name, the server name and the player list are not available from this counter.

There is no fallback option. If the server is offline, the address is wrong, the query port is closed or the game type does not match, the counter fails and the bot renames the channel (or rewrites the topic) to `ERROR!` until the next successful update. The real message is shown on the counter in the dashboard. See [Troubleshooting](/using-the-dashboard/troubleshooting).

{% hint style="info" %}
Put some text around the counter, for example `Online:` , when it feeds a voice or category channel name. A channel name must be at least 2 characters, so a small server that answers with a single digit makes the update fail.
{% endhint %}

## Formatting

The result is a number, so the **Format settings** block applies: **Use compact notation**, **Override default locale** and **Override default digits**. Without compact notation the number is printed plain, with no thousands separator. Custom digits only reach text and announcement channel **topics**; voice and category channel names never show them. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

Nothing is cached. Every channel update sends a fresh query: every 10 minutes on the public bot, every 5 minutes on the legacy premium bot, custom bots and self-hosted bots. See [How counters update](/using-the-dashboard/how-counters-update).

{% hint style="warning" %}
A server that does not answer makes the counter time out.
{% endhint %}

## Examples

{% tabs %}
{% tab title="Minecraft" %}

| Option                   | Value                        |
| ------------------------ | ---------------------------- |
| **Game**                 | `Minecraft` (ID `minecraft`) |
| **Address**              | `mc.example.com`             |
| **Port (or query port)** | `25565`                      |

Written in the template as `Online:` followed by the counter, the channel reads `Online:` plus the player count.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-36074391824981e40abf1819ac80b02c69eee482%2Fcounter-game-example-minecraft.png?alt=media" alt="Minecraft example"><figcaption><p>The counter in the template after <code>Online:</code> , and the Game panel it opens</p></figcaption></figure>
{% endtab %}

{% tab title="ARK" %}

| Option                   | Value                              |
| ------------------------ | ---------------------------------- |
| **Game**                 | `Ark: Survival Evolved` (ID `ase`) |
| **Address**              | `ark.example.com`                  |
| **Port (or query port)** | `27015`                            |

`7777` is the port players connect to. Use `27015`, the query port, which the editor fills in for you when you pick this game.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-6d98a16707e50728a048c62db2aa0d7f1ec153b9%2Fcounter-game-example-ark.png?alt=media" alt="ARK example with the query port"><figcaption><p>The query port is not the port players connect to</p></figcaption></figure>
{% endtab %}

{% tab title="Several servers" %}
Add one Game counter per server inside a [Math](/counters-reference/math) counter whose **Operation type** is **Addition** to show the players across your whole network in one channel, or wrap a counter in [Concat](/counters-reference/concat) to build a sentence around it.

For a server type node-gamedig does not support but that has a public status API, use the [HTTP](/counters-reference/http) counter instead.
{% endtab %}
{% endtabs %}

## Errors

The bot checks the address first, then the port, then the game, so a counter with nothing filled in reports the address message first.

| Message                                                           | What to do                                                                       |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| *The game counter requires a server address.*                     | Fill **Address** with the host, without the port.                                |
| *The game counter requires a server port.*                        | Fill **Port (or query port)**.                                                   |
| *A game ID must be provided for the game counter.*                | Pick a game in **Game**.                                                         |
| *The evaluation took too long and timed out.*                     | Check the query port and the address, and make sure the server is still running. |
| *The result for the channel name is less than 2 characters long.* | Put some text around the counter.                                                |

Any other failure comes from the query itself: the server is offline, the query port is closed or blocked by a firewall, or the game type does not match what the server runs. Check the port first, then the address. More in [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/game.ts`. It calls `GameDig.query` with the game type, the host, the port and `maxRetries: 5`, and returns the `numplayers` field.
* The asserts run before the query, in this order: `GAME_MISSING_ADDRESS`, `GAME_MISSING_PORT`, `GAME_MISSING_GAME_ID`. Anything node-gamedig throws is passed through as it is, so the dashboard shows node-gamedig's own text, not a known error.
* node-gamedig's `attemptTimeout` defaults to 10 s, and every retry runs more than one attempt (the given port with its query port offset, the game's own query port when the given port is the connect port, then the plain options), so 5 retries are at least 10 attempts of 10 s. An unreachable server therefore usually spends the counter's whole 60 s budget (`EVALUATION_TIMEOUT`) before it fails.
* The finished result is trimmed, then cut to 1023 characters on text and announcement topics and to 99 characters on other channel names. A channel name under 2 characters raises `EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`.
* The game list maps every node-gamedig entry to its name and to `port_query ?? port`. That is the number the editor fills in, and it never overwrites a port you already typed.
* `gamedig` is pinned as `^5.3.2` in `packages/services/package.json`. That version ships 356 game types, so the number in the description grows with the dependency.
* No environment variable is involved. A self-hosted bot needs outbound access to the query port: UDP for most protocols, TCP for some, Minecraft among them.

</details>


# Memerator

Fetch the follower count and meme count for a Memerator account.

Memerator reads a public Memerator profile and returns one of its two numbers: how many followers the account has, or how many memes it has posted. Put it in a voice channel name to keep your follower count visible.

**Availability:** every bot. Unlike [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube), it is not restricted to a custom bot. If you [self-host](/self-hosting/self-hosting), set `MEMERATOR_API_KEY` yourself. See [Configuration](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-fa3870cbc6f7885bf123bd49423b48d98f1b642d%2Fcounter-memerator-options.png?alt=media" alt="Memerator options"><figcaption><p>The <strong>Display</strong> and <strong>Username</strong> options</p></figcaption></figure>

## Options

| Option            | What it does                                                  | Notes                                                            |
| ----------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- |
| **Display**       | Chooses what the counter outputs: **Followers** or **Memes**. | Defaults to **Followers**. It does not accept a nested counter.  |
| **Username**      | The Memerator account to read.                                | Required. Type the username, or nest a counter that supplies it. |
| **Display label** | A custom name for this counter's chip in the editor.          | Optional. It only changes the editor.                            |

**Username** also accepts a [nested counter](/using-the-dashboard/nested-counters), for example an [HTTP](/counters-reference/http) counter. It must return the bare username and nothing else.

## What it returns

A plain number: the account's follower count or its meme count.

There is no fallback option. If the counter cannot get a number, the whole template fails and the bot writes `ERROR!` into the channel name on a voice or category channel, or into the channel topic on a text or announcement channel.

## Formatting

Both values are numbers, so every option in **Format settings** applies.

Custom digits only reach text and announcement channel **topics**. Voice and category channel **names** never show them. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

A profile is cached for **30 minutes**, so a new follower can take that long to show. There is no way to force a refresh. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Follower count" %}
Type `Followers:` , then add a **Memerator** counter with **Display** set to **Followers** and **Username** set to your account. The channel reads:

```
Followers: 12K
```

{% endtab %}

{% tab title="Both numbers in one channel" %}
Add the counter twice in the same template, one with **Display** set to **Memes** and one set to **Followers**, and type the labels around them:

```
Memes: 318 | Followers: 12K
```

{% endtab %}
{% endtabs %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-785583494e0788541482f6832ff359d7a527ce71%2Fcounter-memerator-example-followers.png?alt=media" alt="A voice channel showing a Memerator follower count"><figcaption><p>The rendered result in a voice channel</p></figcaption></figure>

## Errors

| Message                                                      | What it means                                                                | What to do                                                 |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------- |
| The Memerator counter requires a username, which is missing. | **Username** is empty, or the counter nested in it returned an empty string. | Fill in **Username**, or fix the counter you nested in it. |

A username that does not exist gives no message: the counter fails and the channel shows `ERROR!`. Check the spelling against the profile page. [Troubleshooting](/using-the-dashboard/troubleshooting) covers a channel stuck on `ERROR!`.

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/Memerator.ts`. It sends a `GET` to `https://api.memerator.me/v1/profile/<username>` with the key in the `Authorization` header, gives it 5 seconds (`AbortSignal.timeout`), and reads `stats.followers` or `stats.memes`.
* The counter's id is `7`. The stored `return` option is `0` for Memes and `1` for Followers, and an absent option evaluates as Followers.
* Cache: key `1:7:<username>` in Redis (the `7` is the counter id), TTL 30 minutes. Every server watching the same account shares that entry and its numbers, concurrent evaluations of the key share one request, and a Redis failure counts as a cache miss instead of an error.
* `MEMERATOR_MISSING_USERNAME` is the only known error the counter raises.
* Without the key the evaluator throws the plain error `MEMERATOR_API_KEY not provided`; an unknown username, or any reply that is not `{ stats: { memes, followers } }`, fails zod validation. Neither is a known error, so the editor card falls back to "An unknown error occurred." and the real text only reaches the bot log.
* Self-hosting: get a key at [memerator.me/api/auth](https://memerator.me/api/auth) with the **Get Profiles - View Profiles** permission and set `MEMERATOR_API_KEY` in `.env`. Without it the counter still appears in **Add counter** and fails at update time.
* Its search keywords include `view`, so searching **Add counter** for "views" surfaces a counter that returns no views.
* Legacy `memeratorFollowers` and `memeratorMemes` templates were converted into this counter with **Display** and **Username** filled in.

</details>


# HTTP

Send a GET request to an endpoint and provide the result to this bot.

The HTTP counter requests a URL and puts the answer into your template. Use it for any API that answers with a short number or a short text.

**Availability:** every bot. It needs no privileged intents.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-650b65295b6f55f615d505a605501e53c787ba43%2Fcounter-http-options.png?alt=media" alt="HTTP counter options"><figcaption><p>The HTTP counter panel with every option filled in. The API key here is a placeholder, so <strong>Test response</strong> still holds the built-in sample and <strong>Preview</strong> reports that the path is not in it</p></figcaption></figure>

## Options

| Option                    | What it does                                                                                | Notes                                                                                                    |
| ------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| **URL (GET)**             | The endpoint the bot requests.                                                              | Required. Only `GET` is supported: no headers, no body, so an API key has to travel in the query string. |
| **Lifetime (in seconds)** | How long the response is cached.                                                            | Optional. Empty means no cache. See [Refresh and caching](#refresh-and-caching).                         |
| **Data path**             | Where the value you want sits inside a JSON response.                                       | Required when the response is JSON, ignored when it is plain text.                                       |
| **Fallback value**        | The value the counter returns when the request fails, so the counter does not break.        | Optional. It covers every failure of the request.                                                        |
| **Display label**         | A custom name for this counter's chip in the editor. It does not change the channel output. | Optional. Open it with the **Edit display label** button.                                                |

**URL (GET)**, **Lifetime (in seconds)**, **Data path** and **Fallback value** each accept another counter instead of a typed value. Hover the gear button on the nested counter's row to read what it has to return. See [Nested counters](/using-the-dashboard/nested-counters).

{% hint style="danger" %}
Anyone who can open your server in the dashboard can read the URL, API key included, so use a read-only key.
{% endhint %}

## Data path

The syntax is the one JS uses to access properties and array items.

| Syntax                 | Example          | What it does                                                                     |
| ---------------------- | ---------------- | -------------------------------------------------------------------------------- |
| `key`                  | `current`        | Reads a property.                                                                |
| `.key`                 | `current.temp_c` | Reads a property of the previous step. Keys with spaces or dashes work unquoted. |
| `["key"]` or `['key']` | `["a.b"]`        | Reads a property whose name contains a dot, a bracket or a quote.                |
| `[0]`                  | `[0].price_usd`  | Reads an array item.                                                             |

{% hint style="warning" %}
`[0]` to `[9]` are the only bracket indexes that work. Write `a["10"].v` or `a.10.v` for anything higher.
{% endhint %}

## What it returns

* A `text/plain` response is returned whole, and **Data path** is ignored.
* An `application/json` response returns the value found at **Data path**.
* Only numbers and strings can be shown. A boolean, `null`, an object or an array fails.
* With a **Fallback value** set, a failed request returns that value and the channel keeps updating. The dashboard still lists the real error on the counter.
* Without one, a failed request renames the channel (or rewrites the topic) to `ERROR!` until the next successful update.

A channel name must be at least 2 characters, so an endpoint that answers with a single character breaks a counter that stands alone in a voice or category channel name. Put some text around it.

## Testing in the dashboard

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5ca39a36abd24cf780b93eb3a77d8496a2209ab7%2Fcounter-http-test-response.png?alt=media" alt="Fetch, Test response and Preview"><figcaption><p>The response and the value the Data path pulls out of it</p></figcaption></figure>

1. Opening a counter that already has a URL fetches it once. On a new counter, type a URL first, then press **Fetch**.
2. **Test response** shows the body that came back, pretty-printed when it is JSON.
3. **Preview** shows the value your **Data path** pulls out of that body, and updates as you type. A wrong path shows the error here instead of the value.

{% hint style="warning" %}
**Fetch** only checks that the URL answers with status 200. It ignores the content type and the cache, so a URL can preview fine and still fail when the bot evaluates the counter.
{% endhint %}

You cannot test a counter whose **Data path** is a nested counter, and **Fetch** does nothing when only **URL (GET)** is one. Preview the whole template instead, as described in [Template editor](/using-the-dashboard/template-editor).

## Formatting

Number formatting applies to numbers only. A `text/plain` body and a JSON string are printed exactly as they arrived, so **Use compact notation**, **Override default locale** and **Override default digits** do nothing for them. To format a value an API sends as a JSON string, put the HTTP counter inside a [Number](/counters-reference/number) counter. The formatting settings then apply, and the conversion drops trailing zeros. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

With **Lifetime (in seconds)** empty or `0` the bot requests the URL on every channel update: every 10 minutes on the public bot, every 5 minutes on the legacy premium bot, custom bots and self-hosted bots. With a lifetime set, the response is stored and reused until it expires. Match it to your API's rate limit: `3600` for a weather endpoint you read once an hour, `300` for a price. See [How counters update](/using-the-dashboard/how-counters-update).

{% hint style="warning" %}
Counters that use the exact same URL share the cached response, in every server.
{% endhint %}

## Examples

More free APIs in the [public-apis list](https://github.com/public-apis/public-apis).

{% tabs %}
{% tab title="Weather" %}
Current temperature in New York from [WeatherAPI](https://www.weatherapi.com/docs/) (free, API key after signing up).

| Option                    | Value                                                                               |
| ------------------------- | ----------------------------------------------------------------------------------- |
| **URL (GET)**             | `https://api.weatherapi.com/v1/current.json?key=<YOUR API KEY>&q=New%20York&aqi=no` |
| **Lifetime (in seconds)** | `3600`                                                                              |
| **Data path**             | `current.temp_c`                                                                    |

The response also carries `current.temp_f` for Fahrenheit and `current.is_day` (`1` by day, `0` by night). Feed `current.is_day` into a [Replace](/counters-reference/replace) counter to turn it into ☀️ or 🌙.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-7b805a1cd73081baae081ce382907d0e1beedddb%2Fcounter-http-example-weather.png?alt=media" alt="Weather example"><figcaption><p>The counter sits between <code>New York</code> and <code>°C</code> in the template</p></figcaption></figure>
{% endtab %}

{% tab title="Air quality" %}
US air quality index for Los Angeles from [IQAir](https://api-docs.iqair.com/) (free, API key after signing up).

| Option                    | Value                                                                                                  |
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
| **URL (GET)**             | `https://api.airvisual.com/v2/city?city=Los%20Angeles&state=California&country=USA&key=<YOUR API KEY>` |
| **Lifetime (in seconds)** | `3600`                                                                                                 |
| **Data path**             | `data.current.pollution.aqius`                                                                         |

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-0ac65cf9ba5bdd8d84ee29f534d90b88b00b5419%2Fcounter-http-example-air-quality.png?alt=media" alt="Air quality example"><figcaption><p>The counter after the text <code>LA AQI -</code> , with the IQAir endpoint, a one hour lifetime and the data path filled in</p></figcaption></figure>
{% endtab %}

{% tab title="Crypto price" %}
Bitcoin price in USD from the [Coinlore API](https://www.coinlore.com/cryptocurrency-data-api) (free, no API key).

| Option                    | Value                                        |
| ------------------------- | -------------------------------------------- |
| **URL (GET)**             | `https://api.coinlore.net/api/ticker/?id=90` |
| **Lifetime (in seconds)** | `300`                                        |
| **Data path**             | `[0].price_usd`                              |

The endpoint answers with an array, which is why the path starts with `[0]`. Coinlore sends the price as a JSON string, so it reaches the channel exactly as written. Wrap the HTTP counter in a [Number](/counters-reference/number) counter if you want compact notation or a different locale applied to it.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-aadda08ca40458f0ce6e155e3d72131ffd4730a7%2Fcounter-http-example-crypto.png?alt=media" alt="Crypto price example"><figcaption><p>Template: <code>BTC: $81502.27</code>, the live price when this was captured</p></figcaption></figure>
{% endtab %}
{% endtabs %}

## Errors

| Message                                                                                   | What to do                                                                                                                          |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| *The HTTP counter requires a URL, which is missing.*                                      | Fill **URL (GET)**. A **Fallback value** does not cover this.                                                                       |
| *The HTTP response status code is invalid (not 200).*                                     | Check the API key and the query string, then press **Fetch**.                                                                       |
| *The content type of the HTTP response is invalid (not text/plain or application/json).*  | Look for another endpoint or another API.                                                                                           |
| *A data path must be specified for HTTP requests whose content type is application/json.* | Fill **Data path**.                                                                                                                 |
| `"<key>" doesn't exists in <path>`                                                        | The path does not match the response. Press **Fetch** and check the **Test response** box.                                          |
| `Only number, string values can be returned, but got <type>`                              | The path stops on an object, an array, a boolean or `null`. Walk one step further, or remember that `[10]` and above need `["10"]`. |
| *The evaluation took too long and timed out.*                                             | Use a faster endpoint, or set a **Lifetime (in seconds)** so the value is reused.                                                   |
| *The result for the channel name is less than 2 characters long.*                         | Put some text around the counter.                                                                                                   |
| *The URL of the HTTP counter points to a private or forbidden network address.*           | On the bots we host, that address is not allowed. Use a public endpoint.                                                            |

Errors a **Fallback value** absorbed are still listed on the counter in the dashboard, so check there when a value looks frozen. More in [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/http.ts`.
* The request sends `User-Agent: Member Counter Discord Bot` and is aborted after 5 seconds.
* The content type is compared after everything from the `;` is cut off, so `application/json;charset=utf-8` passes. A missing `Content-Type` compares as an empty string and fails.
* Cache key: `dataSourceCacheKey(DataSourceId.HTTP, url)`, the counter type plus the URL, with no guild id in it. The first fetch sets the expiry, and an entry that no longer parses is deleted and fetched again.
* Evaluations that want the same key at the same moment share one in-flight request. A Redis failure counts as a cache miss, so the counter still answers, uncached.
* The path walks at most 48 steps, then throws `Path length exceeded`. The bracket index pattern matches a single digit, so `a[10].v` is never parsed: the walk stops at `a` and the type check reports `got object`. `null` and arrays also report `got object`.
* A missing key throws `"<key>" doesn't exists in <the path travelled so far>`, or `"<key>" doesn't exists in the response` when it fails on the first step.
* `HTTP_MISSING_URL` is raised before the fallback is applied, which is why **Fallback value** cannot cover it. Every other failure is absorbed by a fallback when one is set, and reported as a non-fatal error on the counter.
* Each top-level counter of a template races its own 60 s timeout; nested counters share their parent's budget. The finished result is trimmed, then cut to 1023 characters on text and announcement topics and to 99 characters on other channel names.
* **Fetch** in the dashboard uses the same timeout and user agent, with no content type check and no cache. A non-200 comes back as `HTTP <status> <statusText>`.
* Self-hosting: `REDIS_URL` stores the **Lifetime (in seconds)** cache. The evaluator calls plain `fetch` there, so no address is filtered: anyone you let configure counters can point this counter at your LAN or at `localhost` and read the answer.
* The hosted bots swap that for a guarded fetch: it resolves the address, checks the built-in denied ranges and the operator's allowed and denied host lists, and raises `HTTP_FORBIDDEN_TARGET`. Both lists are empty by default. The snapshot refreshes at most every 10 seconds, and its version is part of the cache key, so a new deny drops the bodies cached under the old policy.
* `DATA_SOURCE_HTTP_ALLOW_PRIVATE_ADDRESSES=true` removes that guard. It exists for local development.

</details>


# Math

Perform math operations like addition, subtraction, multiplication, division, or modulus on a list of numbers.

The Math counter takes a list of numbers and reduces it to one number with a single arithmetic operation. Each entry can be a number you type or a [nested counter](/using-the-dashboard/nested-counters), so you can build values Discord does not give you directly, such as the percentage of members who are online.

**Availability:** every bot type. It makes no network requests, so it needs no API key and no environment variable when you [self-host](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8ea2fef425682a0656b090c9e27eaabeb6207232%2Fcounter-math-options.png?alt=media" alt="Math options"><figcaption><p>The Math counter panel: <strong>Operation type</strong> and <strong>Number list</strong></p></figcaption></figure>

## Options

| Option             | What it does                                                      | Notes                                                                                                                                                                                               |
| ------------------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Operation type** | Picks the operation applied to the whole **Number list**.         | Starts on **Addition**, so an untouched Math counter adds.                                                                                                                                          |
| **Number list**    | The operands, used from first to last in the order you list them. | Type a number in the **Add number...** row to append one, or pick a counter from the same box to nest it. Order matters for **Subtraction**, **Division** and **Modulo (Remainder of a division)**. |

Every row of **Number list** takes a nested counter, and that counter must return a number.

### Operation type values

| Value                                | Result                                                                                | With the list 100, 20, 5 |
| ------------------------------------ | ------------------------------------------------------------------------------------- | ------------------------ |
| **Addition**                         | Adds every entry.                                                                     | `125`                    |
| **Subtraction**                      | Subtracts each entry from the running result, from first to last.                     | `75`                     |
| **Multiplication**                   | Multiplies every entry.                                                               | `10000`                  |
| **Division**                         | Divides the running result by each entry, from first to last.                         | `1`                      |
| **Modulo (Remainder of a division)** | Keeps the remainder of dividing the running result by each entry, from first to last. | `0`                      |

## What it returns

A number.

* **Addition** and **Multiplication** start from a seed, so an empty **Number list** gives `0` and `1`.
* **Subtraction**, **Division** and **Modulo (Remainder of a division)** start from the first entry instead. A single entry comes back unchanged, and an empty list breaks the whole template.
* Every entry is read as a number first. A nested counter that returns text the bot cannot read as a number makes the counter fail.
* Dividing by `0` is not an error. It returns `Infinity`, shown as `∞` when compact notation is on.

## Formatting

Math returns a number, so the [Format settings](/using-the-dashboard/customizing-counters) apply to it. The bot reads them from the outermost counter of the chip and applies them once to the final value, and anything you leave unset falls back to the [Server settings](/using-the-dashboard/server-settings).

{% hint style="info" %}
**There is no rounding option.** A Math result keeps its full precision, so a division can come out as `42.857142857142854`. Setting **Use compact notation** to **Yes** is the only way to shorten it: it cuts the value to roughly two significant digits, so that result becomes `43`. It also abbreviates large numbers, so `1234` becomes `1.2K`.
{% endhint %}

## Refresh and caching

Math caches nothing. The bot works it out again on every channel update, every 10 minutes on the public bot and every 5 minutes on the other bot types. See [How counters update](/using-the-dashboard/how-counters-update).

Nested counters keep their own rules, so a value can still lag: [Reddit](/counters-reference/reddit), [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube) serve a cached value, and [HTTP](/counters-reference/http) caches only when you set a **Lifetime (in seconds)**. Twitch and YouTube do not work on the public bot.

## Examples

{% tabs %}
{% tab title="Online percentage" %}
The most common use of Math: turn two member counts into one percentage.

1. Add a **Math** counter and set **Operation type** to **Multiplication**.
2. In its **Number list**, add a nested **Math** counter set to **Division**, then type `100` into the **Add number...** row.
3. Fill the nested division's **Number list** with two nested [Members](/counters-reference/members) counters: first one with **Filter by status** set to **Online + Idle + DND**, then one with no filters. Division works from first to last, so the online count goes first.
4. Open **Format settings** on the outer counter and set **Use compact notation** to **Yes**, so the long decimal is shortened.

With 1 of 2 members online, the division returns `0.5`, the multiplication turns that into `50`, and the channel shows `Online: 50%`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e07752a5e87f5aa9bfaee5cc157696b30f99801a%2Fcounter-math-example-online-percentage.png?alt=media" alt="Online percentage built from nested counters"><figcaption><p>A Multiplication of a nested Division and 100, with compact notation on. On the small test server 1 of 2 members is online, so the channel reads <code>Online: 50%</code></p></figcaption></figure>

The public bot cannot read member presences, so the online count behaves differently there. Read [Members](/counters-reference/members) before you build this one.
{% endtab %}

{% tab title="Humans only" %}
Subtract the bots from the total.

* **Operation type**: **Subtraction**
* **Number list**:
  1. A nested [Members](/counters-reference/members) counter with no filters.
  2. A nested **Members** counter with **Filter by account type** set to **Bot**.

With 120 members of which 8 are bots, the counter returns `112`.
{% endtab %}

{% tab title="Cleaning a value first" %}
Math only accepts numbers. When the value arrives as text, for example a price pulled out of an API by an [HTTP](/counters-reference/http) counter, put a [Replace](/counters-reference/replace) counter around it to clean it up and a [Number](/counters-reference/number) counter around that, then nest the result in **Number list**.
{% endtab %}
{% endtabs %}

## Errors

| What you see                                                        | What to do                                                                                                                                                                                                               |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The channel shows `ERROR!`                                          | The template failed. Open the channel in the dashboard and read the error below.                                                                                                                                         |
| **Failed to generate a final string from the evaluation.**          | Something in **Number list** is not a number. Check every row, wrap text-returning counters in [Number](/counters-reference/number), and strip separators and symbols with [Replace](/counters-reference/replace) first. |
| **The evaluation took too long and timed out.**                     | A counter took more than 60 seconds, usually a nested [Game](/counters-reference/game) counter whose server does not answer, or a long chain of nested requests. Simplify the nesting.                                   |
| **The result for the channel name is less than 2 characters long.** | A voice or category channel name needs at least 2 characters. Add text around the counter in the template, for example `Online:` before it.                                                                              |

If it still fails, [Troubleshooting](/using-the-dashboard/troubleshooting) walks through the common causes, and you can ask on the [support server](https://membercounter.app/support).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/Math.ts`. Every entry goes through `Number()`, then one `reduce` over the list.
* `Subtraction`, `Division` and `Modulo` reduce with no initial value, so an empty list throws `Reduce of empty array with no initial value`.
* `operation` is stored as the `MathDataSourceOperation` enum: ADD `0`, SUBTRACT `1`, MULTIPLY `2`, DIVIDE `3`, MODULO `4`. The evaluator's `switch` shares its `default` with ADD, so an unset or unknown value adds.
* A `NaN` result raises the known error `FAILED_TO_RETURN_A_FINAL_STRING`. `Infinity` is not `NaN`, so it passes that check and is printed.
* On a failed template the update job writes the `jobs.updateChannels.computeError` string into the name or topic: `ERROR!` in en-US, translated in the other locales.
* Compact notation runs through `Intl.NumberFormat(locale, { notation: "compact" })`, and the custom-digit substitution runs only when `isTextLikeChannel(channelType)` is true.
* Global limits: 60 s per counter in the template, shared with everything nested inside it (`EVALUATION_TIMEOUT`), 50,000 tree-exploration iterations, and a final result that is trimmed and then cut to 1023 characters for a topic or 99 for a channel name, with the 2-character minimum (`EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`) checked before the cut.
* Typing text into the **Add number...** row stores `NaN` with no guard, so it fails only at evaluation time; editing an existing row ignores a value that is not a number instead.
* No network access, no cache entry, no environment variable when self-hosting and no premium gating: the evaluator is identical in the FOSS and hosted repositories.

</details>


# Number

Apply number formatting to the given number.

The Number counter turns a value into a number so that number formatting applies to it. On its own it changes nothing you can see. Its whole job is to make **Use compact notation**, **Override default locale** and **Override default digits** work on a value the bot would otherwise treat as text.

**Availability:** every bot type. It makes no network requests, so it needs no API key and no environment variable when you [self-host](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-56763824d4438c1a1a00f2b49316e721d73a37b2%2Fcounter-number-options.png?alt=media" alt="Number options"><figcaption><p>The Number counter panel, with a nested HTTP counter in the <strong>Number</strong> field</p></figcaption></figure>

Counters that return text even when the text looks like a number:

* [HTTP](/counters-reference/http) returns the value at your **Data path**. A quoted JSON field arrives as text; a bare JSON number already arrives as a number.
* [Reddit](/counters-reference/reddit) returns text when it is set to return the subreddit title; its member counts are already numbers.
* [Clock](/counters-reference/clock) and [Date](/counters-reference/date) return a formatted string.
* [Concat](/counters-reference/concat) and [Replace](/counters-reference/replace) always return text.

## Options

| Option     | What it does                      | Notes                                                                                       |
| ---------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
| **Number** | The value to convert to a number. | One value only. Type a number into the box, or pick a counter from the same box to nest it. |

The **Number** field takes a [nested counter](/using-the-dashboard/nested-counters), which is the normal way to use this counter. The nested counter has to return a valid number.

## What it returns

The value read as a number.

| Input                           | Result                          |
| ------------------------------- | ------------------------------- |
| `1234`                          | `1234`                          |
| `12` (with spaces around it)    | `12`                            |
| `1e3`                           | `1000`                          |
| `0x10`                          | `16`                            |
| an empty string                 | `0`                             |
| `1,234`                         | not a number, the counter fails |
| `$99`                           | not a number, the counter fails |
| `99.4%`                         | not a number, the counter fails |
| the **Number** field left empty | not a number, the counter fails |

{% hint style="warning" %}
Thousands separators, currency symbols and percent signs are the usual cause of a broken Number counter. Strip the extra characters with a [Replace](/counters-reference/replace) counter nested inside the **Number** field first.
{% endhint %}

An empty string quietly becomes `0` instead of failing, so a nested counter that sometimes returns nothing shows `0` rather than an error.

Number does not round. `42.857142857142854` stays `42.857142857142854`, and **Use compact notation** is the only way to shorten a value.

## Formatting

This is the point of the counter, so all of the [Format settings](/using-the-dashboard/customizing-counters) apply: **Use compact notation**, **Override default locale**, and **Override default digits**, where you give each digit from 0 to 9 the character or emoji it is drawn with.

**Override default locale** only changes anything when **Use compact notation** is on. It picks the language the shortened form is written in.

{% hint style="warning" %}
The bot reads the formatting from the **outermost** counter of the chip, so Number has to be the outer counter for its own **Format settings** to matter. A Number counter buried inside a [Concat](/counters-reference/concat) or [Replace](/counters-reference/replace) counter only changes the value's type: the chip still ends up as text and the formatting is still skipped.
{% endhint %}

Custom digits only reach text and announcement channel topics, never voice or category channel names, because Discord channel names cannot show them.

## Refresh and caching

Number caches nothing. The bot works it out again on every channel update, every 10 minutes on the public bot and every 5 minutes on the other bot types. See [How counters update](/using-the-dashboard/how-counters-update).

Whatever you nest inside it keeps its own refresh behaviour, so an [HTTP](/counters-reference/http) counter with a **Lifetime (in seconds)** of `300` still only refetches every five minutes.

## Examples

{% tabs %}
{% tab title="Formatting a price from an API" %}
The Bitcoin price comes back from the API as text, so on its own it can never be shortened. Nesting it in a Number counter fixes that.

1. Add an [HTTP](/counters-reference/http) counter with **URL (GET)** `https://api.coinlore.net/api/ticker/?id=90`, **Data path** `[0].price_usd` and **Lifetime (in seconds)** `300`.
2. Add a **Number** counter and put the HTTP counter into its **Number** field, so Number is the outer counter.
3. Open **Format settings** on the Number counter and set **Use compact notation** to **Yes**.

The channel then shows the price shortened, such as `81K`, instead of the full number. Leave compact notation off and you get the raw number exactly as it came in.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-4c5863af032b704cecad1e5c055cb3639f003d88%2Fcounter-number-example-price.png?alt=media" alt="An HTTP price wrapped in a Number counter"><figcaption><p>HTTP fetches the price as text; the Number counter around it makes compact notation apply</p></figcaption></figure>
{% endtab %}

{% tab title="Fancy digits on a text value" %}
Custom digits are part of the number formatting, so the bot skips them for any counter that returns text. Put a Number counter around it, then turn on **Override default digits** in the outer counter's **Format settings** and set the character for each digit.

Remember that custom digits only show up in text and announcement channel topics.
{% endtab %}

{% tab title="Feeding Math" %}
[Math](/counters-reference/math) needs numbers. A value that arrives as text, such as an [HTTP](/counters-reference/http) JSON string or a [Reddit](/counters-reference/reddit) counter set to the subreddit title, has to go through a Number counter first.
{% endtab %}
{% endtabs %}

## Errors

| What you see                                                        | What to do                                                                                                                                                                                        |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The channel shows `ERROR!`                                          | The template failed. Open the channel in the dashboard and read the error below.                                                                                                                  |
| **Failed to generate a final string from the evaluation.**          | The value could not be read as a number. Refill the **Number** field, and clean commas, currency symbols or percent signs with a [Replace](/counters-reference/replace) counter nested inside it. |
| **The result for the channel name is less than 2 characters long.** | A voice or category channel name needs at least 2 characters. Add text around the counter in the template, for example `Members:` before it.                                                      |

If it still fails, [Troubleshooting](/using-the-dashboard/troubleshooting) covers the common causes, and you can ask on the [support server](https://membercounter.app/support).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/number.ts`, a single `Number(options.number)`, so the accepted inputs are the JavaScript coercion rules.
* `NaN` raises the known error `FAILED_TO_RETURN_A_FINAL_STRING`; the update job then writes `jobs.updateChannels.computeError` (`ERROR!` in en-US, translated in the other locales) into the name or topic.
* Why the counter is needed: a string result is returned untouched, and only a value that is already a number gets formatted.
* The format settings are taken once from the root counter's `format`, falling back to the guild's `formatSettings`, and forced onto every nested node, so a nested counter's own format is never read. The dashboard matches this and only ever writes the format of the outermost counter.
* Order of operations: compact notation through `Intl.NumberFormat(locale, { notation: "compact" })` first, then the custom-digit substitution, which runs only for GuildText and GuildAnnouncement channels.
* The dashboard preview evaluates with the real channel's type and falls back to GuildText only when the channel is not in the bot's cache, so the preview matches the channel.
* Global limits: 60 s per counter in the template, shared with everything nested inside it (`EVALUATION_TIMEOUT`), 50,000 tree-exploration iterations, and a final result that is trimmed and then cut to 1023 characters for a topic or 99 for a channel name, with the 2-character minimum (`EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`) checked before the cut.
* No network access, no cache entry and no environment variable when self-hosting.

</details>


# Concat

Concatenate multiple strings into one.

The Concat counter glues several pieces of text together into a single value. Each piece can be text you type or a [nested counter](/using-the-dashboard/nested-counters). You mostly need it when another counter's option expects **one** value and you want that value built out of several parts.

**Availability:** every bot type. It makes no network requests, so it needs no API key and no environment variable when you [self-host](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-699131518abeeb06afddce1b55ef061ff03db931%2Fcounter-concat-options.png?alt=media" alt="Concat options"><figcaption><p>The Concat counter panel: a single <strong>Text list</strong></p></figcaption></figure>

## Options

| Option        | What it does                                                     | Notes                                                                                                                      |
| ------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Text list** | The pieces that are joined together, in the order you list them. | Type into the **Add text...** box to append a piece, or pick a counter from it to nest one. There is no separator setting. |

Every row of **Text list** takes a nested counter, and that counter can return anything: the bot turns numbers into text before joining them.

Concat itself nests in options such as:

* the **Input text** of a [Replace](/counters-reference/replace) counter,
* the **URL (GET)** of an [HTTP](/counters-reference/http) counter, where part of the address comes from another counter,
* any option that takes one nested counter but you need several values in it.

## What it returns

A single piece of text: every entry turned into text and stuck together in order. An empty **Text list** returns empty text.

{% hint style="warning" %}
**Nothing is inserted between the items.** A **Text list** of `Online` and `42` returns `Online42`, not `Online 42`. Add the separator as its own entry. It cannot be a space: what you type is trimmed, so a space adds nothing. Use visible text such as `/` instead.
{% endhint %}

## Formatting

A Concat result is text, and the bot only formats numbers, so **Use compact notation**, **Override default locale** and **Override default digits** have no effect on it. That includes numbers produced *inside* the Concat counter: a [Members](/counters-reference/members) counter nested in **Text list** contributes its raw value.

If you need a number formatted, keep [Number](/counters-reference/number) or [Math](/counters-reference/math) as the outermost counter instead, and type the surrounding text directly into the template. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

Concat caches nothing. The bot works it out again on every channel update, every 10 minutes on the public bot and every 5 minutes on the other bot types. Anything nested inside it keeps its own refresh behaviour. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Building a URL" %}
An [HTTP](/counters-reference/http) counter takes a single **URL (GET)**. To put a value from another counter into the middle of that address, build the address with Concat and nest the Concat counter in the **URL (GET)** field.

**Text list**, in order:

1. `https://api.example.com/v1/stats?server=`
2. a nested counter that returns the identifier you need
3. `&format=json`

The HTTP counter then receives one finished address.
{% endtab %}

{% tab title="Joining values with a separator" %}
Because nothing is inserted automatically, the separator is just another entry.

**Text list**, in order:

1. a nested [Members](/counters-reference/members) counter with no filters
2. `/`
3. a nested **Members** counter with **Filter by status** set to **Online + Idle + DND**

That returns something like `120/47`.
{% endtab %}
{% endtabs %}

## Errors

Concat has no errors of its own. What can still go wrong:

| What you see                                                        | What to do                                                                                                                                                                        |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The channel shows `ERROR!`                                          | A counter nested inside **Text list** failed, so the whole template failed. Open the channel in the dashboard and read the error to find the counter at fault.                    |
| **The result for the channel name is less than 2 characters long.** | A voice or category channel name needs at least 2 characters, and an empty **Text list** is the usual reason. Fill **Text list**, or add text around the counter in the template. |

A very long result is cut off rather than failing.

If it still fails, [Troubleshooting](/using-the-dashboard/troubleshooting) covers the common causes, and you can ask on the [support server](https://membercounter.app/support).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/concat.ts`. Entries are stored in `options.strings` as literals or nested counter objects, turned into text with `String()` and joined onto an empty-string seed, which is why an empty list returns empty text instead of failing and why the counter itself can never throw.
* Only numbers go through the result formatter; a string result is returned untouched, which is why the formatting options do nothing here.
* A whitespace-only entry is trimmed before it is stored, so it never becomes a row.
* Global limits: 60 s per counter in the template, shared with everything nested inside it (`EVALUATION_TIMEOUT`), 50,000 tree-exploration iterations, and a final result that is trimmed and then cut to 1023 characters for a topic or 99 for a channel name, with the 2-character minimum (`EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`) checked before the cut.
* When a nested counter makes the template fail, the update job writes `jobs.updateChannels.computeError` into the name or topic: `ERROR!` in en-US, translated in the other locales.
* No network access, no cache entry and no environment variable when self-hosting.

</details>


# Replace

Text replacement, useful in conjunction with other nested counters for comprehensive text modification.

The Replace counter takes a piece of text and swaps parts of it for something else. It is built for cleaning up or rewording what another counter returns, so all of its fields take [nested counters](/using-the-dashboard/nested-counters).

**Availability:** every bot type. It makes no network requests, so it needs no API key and no environment variable when you [self-host](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3245768a2bf05d891f7e0b54ca612572a73d7a2f%2Fcounter-replace-options.png?alt=media" alt="Replace options"><figcaption><p>The Replace counter panel: <strong>Input text</strong> and one replacement card</p></figcaption></figure>

## Options

| Option           | What it does                              | Notes                                                            |
| ---------------- | ----------------------------------------- | ---------------------------------------------------------------- |
| **Input text**   | The text the replacements are applied to. | One value only. Takes a nested counter.                          |
| **Search for**   | The piece of text to look for.            | Plain text only, no regular expressions. Takes a nested counter. |
| **Replace with** | What each match becomes.                  | Takes a nested counter.                                          |

**Search for** and **Replace with** live together on a replacement card. Use **Add replacement** to add another card and **Remove replacement** to delete one. There is no limit on the number of cards.

{% hint style="warning" %}
**`$` has a special meaning in Replace with.** `$$` produces a single `$`, `$&` inserts the text that was matched, and `` $` `` and `$'` insert the text before or after the match. To print a literal `$$`, type `$$$$`.
{% endhint %}

## What it returns

The **Input text** with every replacement card applied, as text. With no **Input text** the result is empty text.

* **The cards run in order**, each on the result of the one before it, so card 2 can match text card 1 has just produced.
* **Matching is literal and case-sensitive**, and every occurrence is replaced, not just the first. Searching for `on` in `Online` matches nothing, because the text starts with a capital `O`.
* **Regular expressions are not supported.**

{% hint style="danger" %}
**You cannot delete text with this counter.** A card whose **Replace with** is empty is skipped instead of removing the search term, and so is a card whose **Search for** is empty. A single space is trimmed and skipped too, so the closest you can get is replacing the term with another visible character.

A nested counter that returns the number `0` also counts as empty and its card is skipped. A `0` you type in yourself is fine, because it is stored as text.
{% endhint %}

## Formatting

A Replace result is text, and the bot only formats numbers, so **Use compact notation**, **Override default locale** and **Override default digits** have no effect on it, nor on any number produced inside it.

When the cleaned-up value is a number and you want it formatted, wrap the Replace counter in a [Number](/counters-reference/number) counter and put the format settings there, because the bot reads the formatting from the outermost counter only. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

Replace caches nothing. The bot works it out again on every channel update, every 10 minutes on the public bot and every 5 minutes on the other bot types. Anything nested inside it keeps its own refresh behaviour, so an [HTTP](/counters-reference/http) counter in **Input text** still refetches on its own **Lifetime (in seconds)**. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Cleaning a value before Number" %}
An API returns a figure with a decimal comma, such as `1234,56`. [Number](/counters-reference/number) cannot read that, so fix the separator first.

1. Add an [HTTP](/counters-reference/http) counter pointed at the endpoint and set its **Data path** so it returns just the figure.
2. Add a **Replace** counter and nest the HTTP counter in **Input text**.
3. Press **Add replacement** once and fill the card: **Search for** `,` and **Replace with** `.`, which turns `1234,56` into `1234.56`.
4. Wrap the whole Replace counter in a [Number](/counters-reference/number) counter so the value becomes a real number and **Format settings** apply to it.

{% hint style="warning" %}
This only works when the value has no thousands separator. A card with an empty **Replace with** is skipped, so a separator cannot be removed, and swapping it for anything else still leaves a character [Number](/counters-reference/number) cannot read. A value like `1.234,56` cannot be cleaned up with this counter.
{% endhint %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-061c739ce520dec1c5b2111ba12ae3b11e979308%2Fcounter-replace-example-clean-number.png?alt=media" alt="Cleaning an API value with Replace before Number"><figcaption><p>The stack in the dashboard: an HTTP counter in <strong>Input text</strong>, one replacement card, and the whole Replace counter inside a Number counter</p></figcaption></figure>
{% endtab %}

{% tab title="Rewording another counter" %}
[Clock](/counters-reference/clock), [Date](/counters-reference/date) and [Reddit](/counters-reference/reddit) all return text you may not want word for word.

* **Input text**: a nested **Clock** counter.
* **Search for**: `AM`, **Replace with**: `in the morning`.

The match is case-sensitive, so `AM` and `am` are two different searches and need two cards.
{% endtab %}

{% tab title="Swapping a value for a label" %}
Turn a bare number into something friendlier.

* **Input text**: a nested [Members](/counters-reference/members) counter with **Filter by status** set to **Online + Idle + DND**.
* **Search for**: `1`, **Replace with**: `one`.

Every occurrence is replaced, so `21` becomes `2one`.
{% endtab %}
{% endtabs %}

## Errors

Replace has no errors of its own. What can still go wrong:

| What you see                                                        | What to do                                                                                                                                                                                       |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The channel shows `ERROR!`                                          | A counter nested in **Input text**, **Search for** or **Replace with** failed, so the whole template failed. Open the channel in the dashboard and read the error to find the counter at fault.  |
| **The result for the channel name is less than 2 characters long.** | A voice or category channel name needs at least 2 characters, and an empty **Input text** is the usual reason. Fill **Input text**, or add text around the counter in the template.              |
| Nothing changes in the channel                                      | A card is skipped because **Search for** or **Replace with** is empty, or the search term does not match because of capitalisation. Fill both fields on every card and check the capitalisation. |

If it still fails, [Troubleshooting](/using-the-dashboard/troubleshooting) covers the common causes, and you can ask on the [support server](https://membercounter.app/support).

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/replace.ts`. It starts from `String(options.text ?? "")` and loops over `options.replacements`, calling `newText.replaceAll(search, replacement)` on the running value.
* Each pass through the loop starts with `if (!search || !replacement) continue`, a falsy check on the raw value: an empty string and the number `0` both skip the card, and the counter itself never throws.
* `String.prototype.replaceAll` with a string pattern is what gives literal, case-sensitive matching of every occurrence, and it is also what expands the `$` patterns in the replacement.
* There is no cap on the number of replacement cards beyond the 50,000 tree-exploration iterations a top-level counter and everything nested in it share.
* A string result is returned untouched by the formatter, which formats only numbers. That is the mechanism behind the formatting caveat above.
* Other global limits: 60 s per top-level counter in the template, shared with everything nested inside it (`EVALUATION_TIMEOUT`), and a final result that is trimmed and then cut to 1023 characters for a topic or 99 for a channel name, with the 2-character minimum (`EVALUATION_RESULT_FOR_CHANNEL_NAME_IS_LESS_THAN_2_CHARACTERS`) checked before the cut.
* When a nested counter makes the template fail, the update job writes `jobs.updateChannels.computeError` into the name or topic: `ERROR!` in en-US, translated in the other locales.
* No network access, no cache entry and no environment variable when self-hosting.

</details>


# Bot stats

Return the total number of users and servers the bot is currently in. (You probably don't need this)

Bot stats shows how big the bot itself is: how many servers it is in, or the sum of the members of those servers. To count your own server, use [Members](/counters-reference/members) instead.

**Availability:** every bot. It needs no API key and no privileged intent, so it also works on a plain [self-hosted bot](/self-hosting/self-hosting).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-2648957e1fb322b9411edde65978eaca96d4fb58%2Fcounter-bot-stats-options.png?alt=media" alt="Bot stats options"><figcaption><p>The <strong>Display</strong> dropdown</p></figcaption></figure>

## Options

| Option            | What it does                                                | Notes                                                       |
| ----------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
| **Display**       | Chooses what the counter outputs: **Servers** or **Users**. | Defaults to **Users**. It does not accept a nested counter. |
| **Display label** | A custom name for this counter's chip in the editor.        | Optional. It only changes the editor.                       |

## What it returns

A plain number.

* **Servers** is how many Discord servers the bot is in.
* **Users** is the sum of every server's member count. It is **not** a count of unique people: someone who is in three of the bot's servers is counted three times.

The numbers describe the bot that serves your server. On a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting) you get that instance's own totals.

{% hint style="info" %}
A bot without the **Server Members Intent**, and that includes the public bot and the legacy premium bot, takes member counts from Discord's approximate counts, and a server whose approximate count is missing counts as 0, so **Users** reads low. A [custom bot](/custom-bot/how-to-get-a-custom-bot) or a self-hosted bot with the [privileged intents enabled](/custom-bot/creating-a-discord-bot-account) reports exact counts.
{% endhint %}

## Formatting

The result is a number, so every option in **Format settings** applies. A bot with 4,800,000 users reads `4.8M` with **Use compact notation** on.

Custom digits only reach text and announcement channel **topics**. Voice and category channel **names** never show them. See [Formatting counters](/using-the-dashboard/customizing-counters).

## Refresh and caching

The counter is not cached, so the value is only as fresh as the channel. Channels are rewritten every 5 minutes on a custom bot, the legacy premium bot and a self-hosted bot, and every 10 minutes on the public bot. Just after a restart the value can read low for one update, and it corrects itself on the next one. See [How counters update](/using-the-dashboard/how-counters-update).

## Examples

{% tabs %}
{% tab title="Server count" %}
Type `In` , add a **Bot stats** counter with **Display** set to **Servers**, then type `servers`. The channel reads:

```
In 12K servers
```

{% endtab %}

{% tab title="Both numbers" %}
Add the counter twice in the same template, one with **Display** set to **Servers** and one set to **Users**:

```
12K servers | 4.8M users
```

{% endtab %}
{% endtabs %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-9b4389e2a99d1f2e4d8419e923d3f591d19bc40f%2Fcounter-bot-stats-example-servers.png?alt=media" alt="A voice channel showing the bot&#x27;s server count"><figcaption><p>The rendered result in a voice channel</p></figcaption></figure>

## Errors

The counter has no error message of its own. It can return `0`, which is too short for a channel name.

| Message                                                         | What it means                                                                                                       | What to do                                                                                              |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| The result for the channel name is less than 2 characters long. | The template is nothing but this counter, and it returned `0`. A channel name must be at least two characters long. | Put text around the counter, as the examples do. Text and announcement channel topics are not affected. |

<details>

<summary>Technical details</summary>

* Evaluator: `packages/services/src/DataSource/DataSourceEvaluator/evaluators/bot-stats.ts`. It sums `userCount` or `guildCount` over the bot's processes and caches nothing.
* The counter's id is `12`. The stored `return` option is `0` for Users, which is also what an absent option evaluates as, and `1` for Servers.
* The totals travel over Redis pub/sub: one request on `bot-stats-req-<botId>`, one reply per process on `bot-stats-res-<botId>`. The consumer resolves as soon as the replies cover `shardCount` shards, or after 1.5 seconds with whatever answered, so a process that is still starting is left out of that update. If nothing answers, the counter returns `0` instead of failing.
* Per process, `userCount` is `isPrivileged ? g.memberCount : (g.approximateMemberCount ?? 0)` summed over the guild cache. `guildCount` is the size of that process's guild cache, guilds Discord reports as unavailable included.
* Each counter gets 60 seconds to evaluate, not the template as a whole. Past that it fails with `EVALUATION_TIMEOUT`, "The evaluation took too long and timed out."
* **Add counter** matches what you type against each counter's id and its keywords, and Bot stats is the only visible counter with no keywords in the locale files, so no word of its name or description finds it. Searching `12` does; otherwise you scroll to it. The keywords would go in `apps/frontend/src/locales/en-US/main.json`.
* Legacy `member-counter-users` and `member-counter-guilds` templates were converted into this counter.
* Unrelated to the hourly "Send bot stats" job, which posts the guild count to discord.bots.gg, top.gg and botsfordiscord.
* Self-hosting: no environment variable of its own. It only needs the Redis the bot already runs with.

</details>


# All commands

The five slash commands the bot adds, and who can run them.

Member Counter adds five slash commands and one right-click entry.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-c5faa832d7b25f8613c6594928346faa6d66cf48%2Fcommand-list-autocomplete.png?alt=media" alt="Discord&#x27;s command picker listing the bot&#x27;s commands"><figcaption><p>Type <code>/</code> and pick Member Counter to see the list.</p></figcaption></figure>

## The commands

| Command                             | What it does                                        | Who can run it                  |
| ----------------------------------- | --------------------------------------------------- | ------------------------------- |
| [`/info`](/commands/info)           | List of useful links and other information.         | Anyone, also in DMs             |
| [`/profile`](/commands/profile)     | Shows related information between you and this bot. | Anyone, also in DMs             |
| [`/invite`](/commands/invite)       | Provides an invite link to add the bot.             | Anyone, also in DMs             |
| [`/configure`](/commands/configure) | Get the link to configure the bot on the website.   | Administrators, in servers only |
| [`/setup`](/commands/setup)         | Set up some basic counters.                         | Administrators, in servers only |

`/setup` appears in the picker once per subcommand: `/setup server`, `/setup twitch` and `/setup youtube`.

`/profile` has a second way in: right-click any member and choose **Apps** > **Profile**. It is the same command with the same reply.

Everything else happens on the [dashboard](https://membercounter.app/dashboard): creating counters, editing templates, formatting and server settings.

## Every reply is ephemeral

Only you see the reply. It is not posted in the channel, and it disappears when you dismiss it or restart Discord. You cannot share one with a message link either, so take a screenshot or send the other person the command to run.

## Who can run `/configure` and `/setup`

Both are marked as **Administrator** commands and work only in a server, so by default only administrators see them in the command picker.

A server owner can hand either command to other roles, or restrict it to certain channels, in **Server Settings** > **Integrations** > **Member Counter**.

{% hint style="warning" %}
`/setup` creates channels. Anyone you allow to run it can create channels through the bot, even without Manage Channels themselves.
{% endhint %}

## Languages

Command names, descriptions and replies are translated into 19 Discord languages, and fall back to English (US). The reply follows the language of the person who ran the command.

You can help translate the bot at [tolgee.membercounter.app](https://tolgee.membercounter.app/). See [How to contribute](/contributing/how-to-contribute).

## When a command fails

The reply is an error message instead. If the bot recognises the problem it explains it in plain words. Otherwise it shows "Something went wrong, please try again later." with an error ID. Either way a **Join support server** button is attached, and quoting the error ID gets you help faster. See [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* The shared handler calls `deferReply({ flags: MessageFlags.Ephemeral })` before dispatching to any command.
* `apps/bot/src/i18n.ts` holds the locale list: 19 locales (en-US, en-GB, es-ES, es-419, ru, cs, de, tr, el, fi, fr, it, ja, ko, nl, no, pl, sv-SE, zh-CN) served from 17 translation files, because en-GB reuses the en-US file and es-419 reuses es-ES. The default is en-US.
* A reply uses the invoker's client locale, but the automatic channel updates use the server's preferred locale, so a channel name reads the same for everyone.
* `/configure` and `/setup` only carry Discord's default Administrator permission and are guild-only. The bot itself runs no permission check, so whatever Server Settings > Integrations allows, works.
* Commands are registered globally at startup. `DISCORD_BOT_INSTANCE_DEPLOY_COMMANDS=false` skips registration; a server id in that variable registers the commands to that server only.
* A recognised error is shown as its `knownErrors.<CODE>` string; anything else falls back to the generic "Something went wrong" message.
* The error ID is the OpenTelemetry trace id of the interaction span and only the hosted bots produce one. A self-hosted bot shows the literal `Error ID: {{ERROR_ID}}`; read the bot's own log instead.

</details>


# /setup

Set up some basic counters.

`/setup` builds a ready-made set of counters: it creates a category, the counter channels inside it, and a template on each channel. It is the only way to create counters without opening the dashboard.

```
/setup server
/setup twitch username:<channel username>
/setup youtube url:<channel URL>
```

**Who can run it:** administrators by default, and only inside a server. See [who can run `/configure` and `/setup`](/commands/bot-commands#who-can-run-configure-and-setup) to give it to other roles.

**What the bot needs:** Manage Channels and Manage Roles. Both are required, because each channel is created together with its permissions. Both are part of the [invite link](/getting-started/adding-bot-to-the-server).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-b9214a8b9debe32f2678f500691cf4a53091cc50%2Fcommand-setup-autocomplete.png?alt=media" alt="Discord autocomplete for /setup"><figcaption><p>The three subcommands, and the option each one asks for.</p></figcaption></figure>

## `/setup server`

No options. It creates the category **📊 Server Stats 📊** and five voice channels, in this order:

| Channel        | It shows             | Counter used                                                       |
| -------------- | -------------------- | ------------------------------------------------------------------ |
| Online members | `Online: <number>`   | [Members](/counters-reference/members), filtered to online members |
| Members        | `Members: <number>`  | [Members](/counters-reference/members)                             |
| Nitro boosters | `Boosters: <number>` | [Nitro boosters](/counters-reference/nitro-boosters)               |
| Roles          | `Roles: <number>`    | [Roles](/counters-reference/roles)                                 |
| Channels       | `Channels: <number>` | [Channels](/counters-reference/channels)                           |

These five work on every bot, including the public bot. On the public bot the member and online numbers come from Discord's approximate counts, so they lag more than the other three. See [How counters update](/using-the-dashboard/how-counters-update).

{% hint style="info" %}
**Online members** does not mean the same thing on every bot. On the public bot and on the legacy premium bot it counts everyone who is not offline: online, idle and do-not-disturb. A custom bot or a self-hosted bot, which have the privileged intents, counts only the members whose status is online. You can change the filter afterwards on the [Members](/counters-reference/members) counter.
{% endhint %}

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3e08f5b6f91086e316f07d91344f7b5af65678df%2Fcommand-setup-server-channels.png?alt=media" alt="The Server Stats category with its five counter channels"><figcaption><p>The result, seen by a member without administrator permissions.</p></figcaption></figure>

## `/setup twitch`

**username** is the name from the channel address, `twitch.tv/`**`yourname`**, not the display name. The command creates the category **📊 `<channel name>` Twitch Stats 📊** with two voice channels: `<number> Followers` and `Viewers: <number>`. While the channel is not live the viewers counter shows the word `offline`. More on the options in [Twitch](/counters-reference/twitch).

## `/setup youtube`

**url** is the full address of the channel, not of a video. The command creates the category **📊 `<channel name>` YouTube Stats 📊** with three voice channels: `<number> Subscribers`, `<number> Videos` and `<number> Views`. More on the options in [YouTube](/counters-reference/youtube).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-03a11d35d04e73be1f39678c22e8f75734b17cd1%2Fcommand-setup-twitch-youtube-channels.png?alt=media" alt="Twitch and YouTube stats categories"><figcaption><p>The Twitch set on the left, the YouTube set on the right.</p></figcaption></figure>

{% hint style="warning" %}
Both subcommands need a bot that can use those counters: the legacy premium bot, a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting). On the public bot nothing is created: the category name itself contains the counter, so the category fails and the command ends in an error. A self-hosted bot also needs the Twitch or YouTube API credentials, see [Configuration](/self-hosting/configuration).
{% endhint %}

## What the reply looks like

The reply updates itself while the work runs, with one line per channel showing whether it is pending, created or failed. The last line points you at `/configure`.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-a155e32d3bad8385ba3cd5dd08e64968507b5193%2Fcommand-setup-server-reply.png?alt=media" alt="The /setup server reply while it runs and when it finishes"><figcaption><p>The same message mid-run and after "Creation completed."</p></figcaption></figure>

## Why the new channels show a lock

Every channel the command creates denies **Connect** to **@everyone**, so members can read the counters but cannot join the voice channels and turn them into a call. Administrators bypass that and see no lock. This is also why the invite link asks for Manage Roles.

To let people in, open **Edit Channel** > **Permissions** on each counter channel and remove the @everyone **Connect** deny there, or sync that channel with the category. Clearing it on the category alone changes nothing, because a channel's own overwrite wins. The counters keep updating either way.

## If something fails

The category is created first, because the channels go inside it. If the category fails, nothing is created and the command ends in an error. If one channel fails, the others are still created, and you make the missing one yourself on the [dashboard](https://membercounter.app/dashboard).

| Message                                                                                | What to do                                                                                                                                                                          |
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "The specified Twitch channel could not be found."                                     | Check **username**. Use the name from the channel address, not the display name.                                                                                                    |
| "The provided YouTube channel URL is invalid."                                         | Paste the address of the channel itself, not of a video or a playlist. Channel id, `@handle` and legacy user addresses all work.                                                    |
| "The bot is not a premium version and lacks certain features needed for this counter." | This bot cannot use Twitch or YouTube counters. Use the legacy premium bot, a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting).              |
| "Something went wrong, please try again later."                                        | Anything the bot does not recognise, including a missing Manage Channels or Manage Roles permission. Quote the error ID in the [support server](https://membercounter.app/support). |

## After the command

Each created channel already has its template enabled, so it appears on the dashboard right away and updates on the normal cycle. From there you can rename it, change the wording around the number or delete it. See [Edit a counter's template](/using-the-dashboard/template-editor) and [Create a counter from scratch](/getting-started/create-from-scratch).

Running `/setup` again always creates a new category and new channels; it never reuses the ones you have.

<details>

<summary>Technical details</summary>

* The category names and channel templates are the locale strings under `interaction.commands.setup` in `apps/bot/src/locales/en-US/main.json`; that is where you change what `/setup` creates.
* The category is created and awaited first; the channels are then created with `Promise.all`, so they finish in any order even though the table lists them in template order.
* Every channel, category included, is created with two overwrites: the bot user allowed Connect + View Channel, the `@everyone` role denied Connect. The channels are not synced to the category, so each one carries its own deny.
* Templates are stored with `isTemplateEnabled: true` and a `LastTemplateUpdateDate` log entry (`GuildSettingsService.channels`), which is why the channel is already live on the dashboard.
* The templates are rendered in the language of the person who ran the command, not the server's language.
* The YouTube option's i18n key is `channelUrl`, but the name Discord shows and accepts is `url`.
* The Twitch and YouTube evaluators assert the premium flag first and otherwise throw `BOT_IS_NOT_PREMIUM`.
* Self-hosting: `/setup twitch` needs `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`, `/setup youtube` needs `YOUTUBE_API_KEY`, and `DISCORD_BOT_INSTANCE_IS_PREMIUM` must stay `true`. The three credentials are empty in `.env.example`, and a missing one throws a plain `Error`, not a `KnownError`, so the reply is the generic "Something went wrong, please try again later." with no hint about the cause.

</details>


# /configure

Get the link to configure the bot on the website.

`/configure` hands you a button that opens the dashboard on the server you ran it in, so you do not have to find the server in a list first.

```
/configure
```

**Who can run it:** administrators by default, and only inside a server. It has no options. See [who can run `/configure` and `/setup`](/commands/bot-commands#who-can-run-configure-and-setup) to give it to other roles.

## What the reply contains

The reply points you at the button, and tells you that `/setup server` can create some basic counters without leaving Discord. That is a real command mention: click it and Discord fills the command in for you.

The **Go to dashboard** button opens the server's page on the dashboard, with its channels and settings already loaded. On a [self-hosted bot](/self-hosting/self-hosting) it points at your own website address instead, see [Configuration](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5cde07d7b293529fb0a46bcdb470a2af93799c27%2Fcommand-configure-reply.png?alt=media" alt="The /configure reply with the Go to dashboard button"><figcaption><p>The reply, with the clickable /setup server mention and the dashboard button.</p></figcaption></figure>

{% hint style="info" %}
You need to be logged in on the dashboard with the same Discord account, and you need Manage Server on that server, before the page will let you change anything.
{% endhint %}

[`/setup`](/commands/setup) creates a ready-made set of counters; `/configure` opens the dashboard, where you can edit them and create any other counter.

<details>

<summary>Technical details</summary>

* Reply and button strings live under `interaction.commands.configure` in the locale files.
* The button URL is `<WEBSITE_URL>/dashboard/servers/<guild id>`, built from `env.WEBSITE_URL`. `/info` uses `OFFICIAL_WEBSITE_URL` instead, so the two can point at different sites on a self-hosted bot.
* `WEBSITE_URL` has no default and must be set when self-hosting; `.env.example` ships `http://localhost:3000`.
* The `/setup server` mention is built at runtime from the registered command id of this bot instance, so it stays clickable and follows the reader's language. If the id cannot be fetched the command throws and the reply becomes the **ERROR!** embed.
* The handler performs no permission check of its own beyond refusing to run outside a guild.

</details>


# /info

List of useful links and other information.

`/info` answers with an embed holding the bot's links and the version it is running.

```
/info
```

**Who can run it:** anyone, anywhere the bot's commands are available, DMs included. It has no options.

## What the reply contains

The embed holds:

| Line                      | Where it goes                                                                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Add me to your server** | Discord's authorisation page for the bot you ran the command on, with the permissions it needs already ticked |
| **Website**               | [membercounter.app](https://membercounter.app)                                                                |
| **Support server**        | [membercounter.app/support](https://membercounter.app/support)                                                |
| **GitHub Repository**     | [github.com/member-counter/bot](https://github.com/member-counter/bot)                                        |
| **Bot version: `vX.Y.Z`** | That version's release page on GitHub                                                                         |

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3194883c648fa21f92cda1f2a9ca02157f743704%2Fcommand-info-reply.png?alt=media" alt="The /info embed with its link list and version line"><figcaption><p>All the links in one reply, ending with the running version.</p></figcaption></figure>

The version line tells you which build answered you. Quote it when you report a problem in the [support server](https://membercounter.app/support), and run `/info` after updating a [self-hosted bot](/self-hosting/self-hosting) to confirm the new version is running.

{% hint style="info" %}
**Add me to your server** always invites the bot you ran the command on. [`/invite`](/commands/invite) behaves differently: its first button invites the public bot, or on a self-hosted bot, the bot its configuration names.
{% endhint %}

On a self-hosted bot, **Website**, **Support server** and **GitHub Repository** keep pointing at the official website, support server and repository until you change them. See [Configuration](/self-hosting/configuration).

<details>

<summary>Technical details</summary>

* The lines are one translated string, `interaction.commands.info.embedReply.description`.
* The invite link is built from the running bot's own client ID, with `scope=bot` and `permissions=269569044`.
* The version is read from the repository's root `package.json` at build time and the link is `<VITE_BOT_REPO_URL>/releases/tag/v<version>`, so on a fork the link only resolves if that tag exists there.
* Env vars behind the links when self-hosting: `OFFICIAL_WEBSITE_URL` for **Website** (defaults to `https://membercounter.app`), `VITE_SUPPORT_URL` for **Support server**, `VITE_BOT_REPO_URL` for **GitHub Repository** and the version link. None of them is `WEBSITE_URL`, which is the dashboard `/configure` opens.
* No contexts and no default member permissions are set, so the command also runs in DMs and group DMs.

</details>


# /invite

Provides an invite link to add the bot.

`/invite` gives you the link that adds Member Counter to a server, with the permissions it needs already selected.

```
/invite
```

**Who can run it:** anyone, in a server or in DMs. It has no options.

## What the reply contains

An embed with the invite link, and up to three buttons:

| Button                       | When it appears                                           | Where it goes                                                  |
| ---------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
| **Add to server**            | Always                                                    | Discord's authorisation page, where you pick the server        |
| **Add to this server again** | Only in a server, and only for members with Manage Server | The same page with the current server already selected         |
| **Join support server**      | Always                                                    | [membercounter.app/support](https://membercounter.app/support) |

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-c844f26c21f48562eb04900d6d69bb81a5e34b9b%2Fcommand-invite-reply.png?alt=media" alt="The /invite reply with its three buttons"><figcaption><p>Run inside a server by someone with Manage Server, so all three buttons show.</p></figcaption></figure>

**Add to this server again** is for repairs: use it when the bot is already in the server but has lost permissions, or after someone edited its role. Re-authorising restores the permission set without removing the bot, and your counters stay as they are.

The link asks for the permissions the bot uses. [Adding the bot to the server](/getting-started/adding-bot-to-the-server) explains what each one is for.

## On a custom or self-hosted bot

On a [custom bot](/custom-bot/custom-bot) or a [self-hosted bot](/self-hosting/self-hosting), the embed link and **Add to server** still invite the public bot. **Add to this server again** is the only button that invites the bot that replied.

[`/info`](/commands/info) always links the bot that replied. When self-hosting you can also make `/invite` advertise your own bot, see [Configuration](/self-hosting/configuration).

<details>

<summary>Technical details</summary>

* The embed text comes from the locale string `interaction.commands.invite.description`.
* The embed link and **Add to server** are generated from `env.MAIN_DISCORD_CLIENT_ID`, which is required. `.env.example` ships the public bot's id, `478567255198662656`, so an unedited self-hosted bot advertises the public bot; set it to your own application id and both advertise your bot. **Add to this server again** is generated from `env.DISCORD_CLIENT_ID`, the running instance, with `guild_id=<current server>` appended so Discord preselects it.
* Every link uses `scope=bot` and `permissions=269569044`: View Channel, Manage Channels, Connect, Send Messages, Read Message History, Embed Links, Manage Roles, Ban Members.
* `ManageGuild` for the second button is checked on the invoker, not on the bot.
* **Join support server** links to `env.VITE_SUPPORT_URL`, the same variable that feeds the support button on error embeds.

</details>


# /profile

Shows related information between you and this bot.

`/profile` shows what the bot knows about a Discord account: its badges, and the buttons to manage that record.

```
/profile
/profile user:<member>
```

**Who can run it:** anyone, in a server or in DMs. The **user** option is optional; leave it out to see your own profile.

## Two ways to open it

Type `/profile` for yourself, or `/profile user:` and pick a member to see theirs. You can also right-click a member, in the member list, in chat or on their avatar, and choose **Apps** > **Profile**. It is the same command, with the same reply.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-dcfba91b09f197c9213a9d66115c078dfdb23e5b%2Fcommand-profile-context-menu.png?alt=media" alt="Apps then Profile in the user context menu"><figcaption><p>The right-click route, on a member in the member list.</p></figcaption></figure>

## What the reply contains

An embed with one field, **Badges**: a code block with the badge emojis that account has earned. An account with no badges gets an empty block. What each badge means is listed in [Badges](/contributing/badges).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-3cceae0592c7bb207fdb8e7cf5ad1a34412519b1%2Fcommand-profile-reply.png?alt=media" alt="The /profile embed showing the Badges field"><figcaption><p>Your own profile, with the Delete data button underneath.</p></figcaption></figure>

These buttons can appear under the embed:

| Button              | When it appears                                                                                  |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| **🗑️ Delete data** | On your own profile, or on anyone's profile if you are staff with the permission to manage users |
| **⚙️ More options** | Only for staff; it opens that account's page on the website                                      |

Only the person who ran the command can press **🗑️ Delete data**, and it stops working after five minutes. The bot then removes the buttons, so run the command again to get them back.

## Deleting your data

**🗑️ Delete data** deletes the whole account record, not just the badges in the embed: your badges, any permissions your account holds on the dashboard, your dashboard preferences and your donation history. It also deletes your custom bots, your credit balance and your unspent server upgrades.

Press it and the bot asks you to confirm. Nothing is deleted until you press **Yes**; **Cancel** takes you back to the profile.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-920616b773559061a329385709898ee558bc02b9%2Fcommand-profile-delete-confirm.png?alt=media" alt="The delete confirmation with Yes and Cancel"><figcaption><p>Nothing is deleted until you press Yes.</p></figcaption></figure>

{% hint style="danger" %}
Deleting is final. Badges are not given back automatically, so anything you earned once is gone. If you have ever donated, ask in the [support server](https://membercounter.app/support) first: the donation records linked to your account go with it.
{% endhint %}

The counters in your servers are untouched: they belong to the server, not to your account. See [Your account](/using-the-dashboard/account) for what else the record holds.

<details>

<summary>Technical details</summary>

* The command upserts a record for the invoker first, then reads the target's. A target with no record throws `USER_NOT_FOUND` and the reply becomes an error embed with "User not found." Your own profile can never hit that, because the upsert runs first.
* Badge order and emoji come from `packages/common/src/UserBadges.ts`: Donor ❤️, Premium 💎, BetaTester 🧪, Translator 🌎, Contributor 💻, BigBrain 🧠, BugCatcher 🐛, PatPat 🐱, FoldingAtHome 🧬.
* **⚙️ More options** links to `<WEBSITE_URL>/admin/users/<user id>`.
* The button collector runs 5 minutes on both `time` and `idle` and filters on the invoker's user id. On timeout the profile is re-rendered with no components, which is why **⚙️ More options** disappears too even though it is a plain link button.
* Confirming deletes the `User` row: `badges`, `permissions` and `prefersAutosave`. Its `Donation` records follow through `onDelete: Cascade`. Guild settings and channel templates live on a separate model and are not touched.

</details>


# What is a custom bot

A custom bot is Member Counter running under your own Discord bot token, on our hosting, with every feature turned on.

A custom bot is Member Counter logged in with **your** own Discord application. It uses your name, avatar and status. It is only in the servers you invite it to, and every feature is on.

We host it and keep it updated. You bring the token and pay the subscription.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-60ad5cd44e907b22e2174a2d15665709405aa69d%2Fcustom-bot-dashboard-overview.png?alt=media" alt="The dashboard page of a custom bot"><figcaption><p>A custom bot in the dashboard: its Discord profile, the <strong>Control panel</strong> and the <strong>Subscription</strong> card</p></figcaption></figure>

## How it compares

|                                                                                             | Public bot                                                             | Legacy premium bot                            | Custom bot              | Self-hosted bot                       |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------- | ----------------------- | ------------------------------------- |
| [Counter refresh](/using-the-dashboard/how-counters-update)                                 | Every 10 minutes                                                       | Every 5 minutes                               | Every 5 minutes         | Every 5 minutes                       |
| [Member](/counters-reference/members) filters by role, account type, game and voice channel | No                                                                     | No                                            | Yes                     | Yes                                   |
| **Online**, **Idle** and **Do Not Disturb**                                                 | Only an approximate **Online**, which includes Idle and Do Not Disturb | Same as the public bot                        | Exact, and told apart   | Exact, and told apart                 |
| [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube) counters    | No                                                                     | Yes                                           | Yes                     | Yes, once you add the API credentials |
| Own name, avatar and [status](/custom-bot/changing-bots-status)                             | No                                                                     | No                                            | Yes                     | Yes                                   |
| Who runs it                                                                                 | We do                                                                  | We do                                         | We do, under your token | You do, on your own machine           |
| Cost                                                                                        | Free                                                                   | Only for servers with a legacy server upgrade | From €5.00 per quarter  | Free, you pay for the machine         |

A self-hosted bot has these features when you keep the default settings and enable the privileged intents on your application. See [Configuration](/self-hosting/configuration).

{% hint style="info" %}
A custom bot is per **bot**, not per server. One custom bot can sit in as many servers as you want, as long as the subscription covers the members it has to keep in memory.
{% endhint %}

## Start here

{% content-ref url="/pages/Q305fnC6L27nKG0pj3hA" %}
[How to get a custom bot](/custom-bot/how-to-get-a-custom-bot)
{% endcontent-ref %}

{% content-ref url="/pages/Fx7QIcY4vrBPVjlfegeZ" %}
[Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account)
{% endcontent-ref %}

{% content-ref url="/pages/BdLljO7ufgk1daDSAfrv" %}
[Setting up your custom bot](/custom-bot/setting-up-custom-bot)
{% endcontent-ref %}

Once it is running, see [Changing the bot's status](/custom-bot/changing-bots-status), [Control panel](/custom-bot/control-panel) and [Managing your subscription](/custom-bot/managing-your-subscription). If you already hold server upgrades, see [Legacy Premium vs. Custom Bot](/custom-bot/legacy-premium-vs-custom-bot).

## The free alternative

Member Counter is open source, so [self-hosting](/self-hosting/self-hosting) gives you the same features for free on your own machine.

If you get stuck, ask at the [support server](https://membercounter.app/support).

<details>

<summary>Technical details</summary>

* The **Update channels** job runs on cron `0 */5 * * * *` when `DISCORD_BOT_INSTANCE_IS_PREMIUM` is `true` and on `0 */10 * * * *` when it is `false`. Custom bots are launched with that flag and `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` hard-coded to `true`, and `.env.example` ships both as `true`.
* The bot always asks for the `Guilds`, `GuildEmojisAndStickers` and `GuildModeration` intents, adds the privileged `GuildMembers` and `GuildPresences` only when it is privileged **and** premium, and `GuildVoiceStates` when it is premium. The member, presence and voice caches follow the same rule.
* Without those intents the Members counter can only return `guild.approximateMemberCount`, `guild.approximatePresenceCount` (served for both the **Online** filter and the one that groups Online, Idle and Do Not Disturb) and the two subtracted for **Offline**. The role, account type, game and voice filters and the **Idle** and **Do Not Disturb** statuses throw `BOT_IS_NOT_PREMIUM`, or `BOT_HAS_NO_ENOUGH_PRIVILEGED_INTENTS` when the bot is premium but the intents are off.
* The Twitch and YouTube evaluators check for premium before anything else, so they fail as a whole on the public bot instead of option by option. Self-hosting them needs `YOUTUBE_API_KEY` for YouTube and `TWITCH_CLIENT_ID` with `TWITCH_CLIENT_SECRET` for Twitch.
* A custom bot runs as a single-shard StatefulSet (shard 0 of 1) with `DISCORD_BOT_INSTANCE_DISCORD_API_RPS=50` against the public bot's `45`, `DISCORD_BOT_INSTANCE_DEPLOY_COMMANDS=true` so it registers its own slash commands, and the bot-list stats tokens blank. Custom bot launch settings live in `packages/services/src/customBots/index.ts`.

</details>


# How to get a custom bot

What a custom bot costs, how the quota works, and how to create one from the dashboard.

A custom bot needs a [Discord bot token](/custom-bot/creating-a-discord-bot-account) and a subscription. Creating it in the dashboard is free; the token and the payment are in [Setting up a custom bot](/custom-bot/setting-up-custom-bot).

## What it costs

A custom bot costs **€5.00 per quarter for every \~50,000 members** it is going to serve. You set that size in the **Subscription** card: each step adds \~50,000 members and €5.00 to the price.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-7f540ea4058cefe6657face6fe674291c69d4518%2Fcustom-bot-quota-picker.png?alt=media" alt="The Subscription card with the quota picker"><figcaption><p>The minus and plus buttons set the size of the subscription</p></figcaption></figure>

### What a quota unit really is

The member figure is a guideline, not a hard limit. What we monitor is memory: one quota unit is a fixed slice of RAM and CPU, and \~50,000 members is roughly what fits in one slice.

So pick the number of units that matches the total members across every server your bot will join. If the bot misbehaves below that number, write to us at the [support server](https://membercounter.app/support): we can raise your capacity for free instead of charging you for another unit.

{% hint style="warning" %}
If the bot runs out of memory it is stopped, and the [Control panel](/custom-bot/control-panel) tells you to upgrade your plan. You can raise the quota at any time from the **Subscription** card.
{% endhint %}

### How you pay

There are two payment methods:

* **PayPal.** Payments must be in **EUR**. A payment in any other currency is refunded automatically and the subscription cancelled.
* **Account credit.** If your account has a balance, the subscribe dialog offers a **Subscribe using my credit** button. You can only pay this way when the balance covers the whole price.

Credit is granted by our staff in special cases only. **It cannot be bought from the dashboard.** When you have some, it shows on your [account page](/using-the-dashboard/account).

Both methods are walked through in [Setting up a custom bot](/custom-bot/setting-up-custom-bot). Renewals, upgrades, pausing and cancelling are in [Managing your subscription](/custom-bot/managing-your-subscription).

## Create the custom bot

Creating the bot is free and instant. Nothing is charged until you subscribe.

{% stepper %}
{% step %}

### Open the dashboard

Go to the [dashboard](https://membercounter.app/dashboard) and sign in with Discord.
{% endstep %}

{% step %}

### Click Create custom bot

Click **Create custom bot** at the top of the bot and server selector in the sidebar.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-5a11ee06a446ef822b31b4604e438374a9cc4d47%2Fcustom-bot-create-button.png?alt=media" alt="The Create custom bot button in the sidebar"><figcaption><p>The <strong>Create custom bot</strong> button sits above the server list</p></figcaption></figure>
{% endstep %}

{% step %}

### You land on the new bot's page

The dashboard creates an empty bot, with no token and no subscription, and opens its page. From here, follow [Setting up a custom bot](/custom-bot/setting-up-custom-bot).
{% endstep %}
{% endstepper %}

{% hint style="info" %}
Right after creating a bot you have to wait a few seconds before you can create another one. You can create as many custom bots as you like, but each one needs its own subscription to run.
{% endhint %}

<details>

<summary>Technical details</summary>

* `CUSTOM_BOT_QUOTA_UNIT_PRICE` is `500n`, in cents, with `DEFAULT_CURRENCY` `EUR`; both live in `packages/common/src/Constants.ts`. The subscription is created with an `interval` of 3 months.
* The quota picker runs from `1` to `PAYPAL_CUSTOMBOTS_MAX_QUOTA`, `50` in `.env.example`, so the largest subscription the dashboard sells is €250.00 per quarter. The member figure printed under the price is the quota multiplied by 50,000.
* One unit is `K8S_CUSTOMBOTS_BASE_QUOTA_CPU` millicores of CPU and `K8S_CUSTOMBOTS_BASE_QUOTA_MEM` MiB of memory (`50` and `100` in `.env.example`), multiplied by the quota and by the bot's `quotaMultiplier`. Node.js gets 75% of the container memory as its heap through `--max-old-space-size`.
* `quotaMultiplier` is a per-bot number, `1` by default, that staff raise to give a bot more CPU and memory without changing the price. That is the capacity increase support grants for free.
* Kubernetes kills the container when it passes the memory limit, and the control panel then reports the pod status `OOM_KILLED` with "Your bot was stopped because it exceeded the memory limits of your current plan. Upgrade your plan to prevent this."
* Credit is a `BigInt` on the user row, in euro cents like the prices. Spending it creates a `CreditSubscription`, which charges the balance again every `interval` months, and refunds add the amount back. Only staff with `UserPermissions.ManageUsers` can write it, and no code path turns a donation into credit on its own.
* Creating a bot inserts a row with your user id and an empty activity, no token and no payment, then opens `/dashboard/bots/<botId>`. `/dashboard/bots` without an id redirects to your first bot, or to `/dashboard` when you have none.
* The 15-second cooldown after creating a bot is enforced in the dashboard only. Nothing on the server limits how many custom bots one account may create.

</details>


# Creating a Discord bot account

Create the Discord application, turn on the two privileged intents, and copy the token your bot logs in with.

Both a [custom bot](/custom-bot/how-to-get-a-custom-bot) and a [self-hosted bot](/self-hosting/self-hosting) log in with your own Discord application. You create it once, in the Discord Developer Portal.

{% hint style="danger" %}
The bot token is a password. Anyone who has it can log in as your bot, read what it can read and post as it. Never paste it in a chat, a screenshot or an issue. **Our staff will never ask you for your token.**
{% endhint %}

## Create the application

{% stepper %}
{% step %}

### Open the Developer Portal

Open [discord.com/developers/applications?new\_application=true](https://discord.com/developers/applications?new_application=true) and sign in with the Discord account that should own the bot. That link opens the **New Application** dialog for you; you can also click **New Application** at the top right of the portal.
{% endstep %}

{% step %}

### Create the application

Give it a name, accept the developer terms and click **Create**. The name you type here is the name your bot shows in Discord, so use the one you actually want, and use a dedicated application instead of reusing a bot you already run.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-edaf8d184d3e5fd48e0babb4ab146f18eb98f81b%2Fdevportal-new-application.png?alt=media" alt="The New Application dialog in the Discord Developer Portal"><figcaption><p>Name the application and click <strong>Create</strong></p></figcaption></figure>

Upload the avatar and banner here too; this is the only place that changes the bot's Discord profile.
{% endstep %}
{% endstepper %}

## Turn on the privileged intents

Open the **Bot** tab and scroll to **Privileged Gateway Intents**. Member Counter needs two of the three. Set them as below and click **Save Changes**.

| Intent                     | Set it to | Why                                                                                        |
| -------------------------- | --------- | ------------------------------------------------------------------------------------------ |
| **Presence Intent**        | **On**    | Tells Online, Idle and Do Not Disturb apart, and powers the "playing a game" member filter |
| **Server Members Intent**  | **On**    | Counts members exactly and powers the role and account-type filters                        |
| **Message Content Intent** | **Off**   | Member Counter never reads message content                                                 |

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-e3e4ff5e05a3089eab52d3b33c4f78bd29332f66%2Fdevportal-bot-intents.png?alt=media" alt="The Privileged Gateway Intents section with Presence and Server Members enabled"><figcaption><p><strong>Presence Intent</strong> and <strong>Server Members Intent</strong> on, <strong>Message Content Intent</strong> off</p></figcaption></figure>

{% hint style="warning" %}
The bot asks Discord for these two intents at login, so it **cannot start** while they are off.
{% endhint %}

## Copy the token

Still in the **Bot** tab, click **Reset Token** and confirm. Discord shows the token **once**. Copy it and keep it somewhere safe.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-934106d0d13dee1d0506daff074e8ed0f6e1e40d%2Fdevportal-reset-token.png?alt=media" alt="The Reset Token button in the Bot tab"><figcaption><p>The <strong>Reset Token</strong> button on the <strong>Bot</strong> tab. The new token is shown only once, right after you confirm</p></figcaption></figure>

Resetting the token invalidates the old one, so a bot already running on it stops working until you paste the new one. Where the token goes next:

* **Custom bot:** paste it into **Discord Bot Token** in the dashboard. See [Setting up a custom bot](/custom-bot/setting-up-custom-bot).
* **Self-hosted bot:** put it in `DISCORD_BOT_INSTANCE_TOKEN` in your `.env`. See [Configuration](/self-hosting/configuration).

## OAuth2 credentials

Self-hosting only: a self-hosted bot also needs the OAuth2 client id, secret and redirect URL from the same application. See [Configuration](/self-hosting/configuration).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-6d6dfd7691db23bb6a9a7dac08ead790f466e9f1%2Fdevportal-oauth2-credentials.png?alt=media" alt="The OAuth2 tab showing the client id, the reset secret button and the redirects list"><figcaption><p>The <strong>OAuth2</strong> tab of the same application</p></figcaption></figure>

<details>

<summary>Technical details</summary>

* Every bot requests `Guilds`, `GuildEmojisAndStickers` and `GuildModeration`, plus `GuildMembers`, `GuildPresences` and `GuildVoiceStates` for a privileged premium bot. `MessageContent` is never requested. The list is in `packages/common/src/bot/botIntents.ts`.
* Before deploying anything, a custom bot start logs in for real with the privileged intents. A failure throws `BOT_START_PRE_CHECK_FAILED`, shown as **Bot failed to start (check the token or intents)**; no saved token throws `BOT_TOKEN_MISSING`, **The bot must have at least a token**.
* A self-hosted bot with an intent off exits with Discord's disallowed-intents error instead of starting. Past 100 servers Discord only keeps granting the intents through bot verification; below that the toggles are enough.
* The dashboard never asks for a client id: it calls Discord's `/users/@me` with the saved bot token, which is why the profile row fills in as soon as you paste a valid one. With no usable token it shows the bot as **Unknown Bot**.
* The application id is base64-decoded from the token's first segment, for the sidebar list and for the `DISCORD_CLIENT_ID` the deployed pod gets.
* Variables a self-hoster fills from this application: `DISCORD_BOT_INSTANCE_TOKEN`, `DISCORD_CLIENT_ID` (the application id), `DISCORD_CLIENT_SECRET`, `DISCORD_OAUTH2_REDIRECT_URI`, mandatory and with no default (`http://localhost:3000/api/auth/callback` in `.env.example`; set it to your `WEBSITE_URL` followed by `/api/auth/callback` yourself), and `MAIN_DISCORD_CLIENT_ID`.
* `MAIN_DISCORD_CLIENT_ID` ships as `478567255198662656`, the official bot, so `/invite` on a self-hosted bot links the official bot until you put your own client id there.
* The redirect has to match character for character, including the scheme, the port and the absence of a trailing slash. A mismatch gives Discord's "Invalid OAuth2 redirect\_uri" error when someone signs in to your dashboard.

</details>


# Setting up your custom bot

Paste the token, subscribe, start the bot from the control panel and invite it to your server.

You need a custom bot in the dashboard ([How to get a custom bot](/custom-bot/how-to-get-a-custom-bot)) and a Discord application with a token ([Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account)). Open your bot's page in the [dashboard](https://membercounter.app/dashboard) by picking it from the sidebar selector, then do the steps below in this order.

## 1. Paste the token

In the **Bot configuration** card, paste the token you copied from the Developer Portal into **Discord Bot Token** and click **Save**. Autosave is off by default and is switched on from [your account](/using-the-dashboard/account).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-c5f9515cbeceeb9af2644c7ce35b329ed92da1e4%2Fcustom-bot-token-field.png?alt=media" alt="The Discord Bot Token field in the Bot configuration card"><figcaption><p>Paste the token into <strong>Discord Bot Token</strong> and click <strong>Save</strong></p></figcaption></figure>

As soon as you paste a valid token, the card reads your bot's profile from Discord and shows its name and avatar. **Invite bot** stays disabled until you save a token.

{% hint style="info" %}
A running bot applies a change to **Status**, **Type**, **Name**, **State** or **URL** live. The token is the exception: the bot restarts by itself so it can log in again. See [Changing the bot's status](/custom-bot/changing-bots-status).
{% endhint %}

## 2. Subscribe

A custom bot only runs while it has an active subscription.

{% stepper %}
{% step %}

### Pick your quota

In the **Subscription** card, use the minus and plus buttons to pick how many units the servers you plan to add the bot to need. The price above updates as you go. What a unit buys is explained in [How to get a custom bot](/custom-bot/how-to-get-a-custom-bot#what-it-costs).
{% endstep %}

{% step %}

### Click Subscribe and agree to the terms

The **Setup subscription** dialog opens. Billing starts right away, and you can upgrade the quota later at any time. Tick the terms checkbox: the payment buttons stay unusable until you do.
{% endstep %}

{% step %}

### Pay

You can pay in two ways:

* The **PayPal** button, which opens PayPal's own subscribe flow. The payment has to be in **EUR**; anything else is refunded automatically and the subscription cancelled.
* **Subscribe using my credit**, which only appears when your account has credit, and stays disabled while the balance is below the price.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-866c01b608c13db50f375e58aa7baf8536878c3f%2Fcustom-bot-subscription-dialog.png?alt=media" alt="The Setup subscription dialog"><figcaption><p>The <strong>Setup subscription</strong> dialog with the terms checkbox and both payment methods</p></figcaption></figure>
{% endstep %}
{% endstepper %}

Once the payment goes through, the **Subscription** card shows the date the subscription expires on. Renewals, upgrades, **Pause**, **Resume** and **Cancel** are in [Managing your subscription](/custom-bot/managing-your-subscription).

## 3. Start the bot

Go to the **Control panel** card and click the power button. Its tooltip tells you the current state.

Starting takes a moment. Once the bot runs, the card shows its status and how much of your CPU and RAM quota it is using.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-88d503b6100246c4fdc51f5f30778aea4f06cf18%2Fcustom-bot-power-button.png?alt=media" alt="The Control panel card"><figcaption><p>The power button in the <strong>Control panel</strong>, with the tooltip showing the current state</p></figcaption></figure>

If the start fails, the dashboard tells you why:

| Message                                              | What to do                                                                                                                                                                                   |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Payment required**                                 | There is no active subscription. Go back to step 2.                                                                                                                                          |
| **The bot must have at least a token**               | The subscription is fine, but no token is saved. Go back to step 1.                                                                                                                          |
| **Bot failed to start (check the token or intents)** | Discord refused the login. Either the token is wrong or expired, or the [two privileged intents](/custom-bot/creating-a-discord-bot-account#turn-on-the-privileged-intents) are not both on. |

Every other message the **Control panel** can show is listed in [Control panel](/custom-bot/control-panel).

## 4. Invite it to your server

Click **Invite bot** at the top of the bot's page, pick the server and authorise it. The link already asks for the permissions Member Counter needs, and you need the **Manage Server** permission in the server you pick. See [Adding the bot to your server](/getting-started/adding-bot-to-the-server).

{% hint style="info" %}
**Your custom bot takes over from the public bot.** In a server where both are present, your custom bot claims the counters and the public bot stops updating them. You do not need to kick the public bot, and nothing you already set up is lost.
{% endhint %}

From here, set up your counters exactly as you would with the public bot: see [Quick setup](/getting-started/quick-setup).

If something goes wrong, ask at the [support server](https://membercounter.app/support) or check [Troubleshooting](/using-the-dashboard/troubleshooting).

<details>

<summary>Technical details</summary>

* Saving a changed token restarts the bot: the pod is stopped, waited for and started again. It does nothing while the bot is stopped. A changed status or activity is pushed to the running bot instead, with no restart.
* `start` checks in this order: the subscription expiry (`PAYMENT_REQUIRED`), whether a StatefulSet already exists, the saved token (`BOT_TOKEN_MISSING`), then the login pre-check (`BOT_START_PRE_CHECK_FAILED`). A brand-new bot therefore reports **Payment required** before it complains about the missing token.
* Starting creates a Kubernetes StatefulSet named after the bot id, so the pod is `<botId>-0`. Nothing is deployed until the three checks pass, and the pod carries a `membercounter.app/bot-tier=premium` toleration.
* **Invite bot** links to `/invite?botId=<appId>`, with the application id taken from the saved token.
* An expired subscription keeps the bot running for `SUBSCRIPTION_GRACE_PERIOD`, 3 days. After that a scheduled job clears the bot's desired state and stops it. Start, stop, restart and the expiry job live in `packages/services/src/customBots/index.ts`.
* The takeover runs on a per-guild priority in Redis. Every instance advertises its `DISCORD_BOT_INSTANCE_COMPUTE_PRIORITY` on cron `0 */5 * * * *` with a 900 second TTL, a stored value is only overwritten by one that is equal or higher, and an instance updates only the guilds whose advertised priority is at most its own. Public bots advertise `0`, custom bots `2`.

</details>


# Changing the bot's status

Set the presence, activity and token of your custom bot from the Bot configuration card.

Your custom bot runs under your own name and avatar. Set what its presence says in the **Bot configuration** card on your bot's page in the [dashboard](https://membercounter.app/dashboard).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-ffd35bce4610ce8f0b252df1e7a37d521dc664f9%2Fcustom-bot-configuration-card.png?alt=media" alt="Bot configuration card"><figcaption><p>The <strong>Bot configuration</strong> card, with the live <strong>Status preview</strong> on the right</p></figcaption></figure>

## The fields

| Field                 | What it does                                       | Notes                                                                                                                                                                                                  |
| --------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Discord Bot Token** | The token your bot logs in with.                   | Explained in [Setting up your custom bot](/custom-bot/setting-up-custom-bot). Saving a **new** token restarts the bot. Until a working token is saved, the bot shows as unknown and cannot be invited. |
| **Status**            | The coloured dot next to the bot's name.           | **Online**, **Idle**, **Do not disturb** or **Invisible**. Default: **Online**.                                                                                                                        |
| **Type**              | The verb in front of the activity.                 | **Custom**, **Playing**, **Listening to**, **Streaming**, **Watching** or **Competing in**. Default: **Custom**.                                                                                       |
| **Name**              | The activity name, the part that follows the verb. | Hidden when **Type** is **Custom**: a custom status has no verb to attach it to.                                                                                                                       |
| **State**             | The free-text line of the presence.                | This is the whole status text when **Type** is **Custom**. Default: `Counting everything!`                                                                                                             |
| **URL**               | The stream the bot links to.                       | Only shown when **Type** is **Streaming**. Use a YouTube or Twitch link.                                                                                                                               |

The **Status preview** redraws as you type, so you see what Discord will show before you save.

{% hint style="info" %}
The bot's avatar, username and banner belong to the [Discord application](/custom-bot/creating-a-discord-bot-account), so the card only shows them. **Edit bot details** opens the application in the Discord Developer Portal.
{% endhint %}

## Saving

Click **Save** to apply your changes. With **Autosave** on in your [account settings](/using-the-dashboard/account), the card saves by itself shortly after you stop typing.

**Status**, **Type**, **Name**, **State** and **URL** reach the running bot straight away, without a restart, so your counters keep updating. The change is sent once and never retried: if the presence in Discord does not change, save again.

{% hint style="warning" %}
The **Discord Bot Token** is the exception. Saving a token that differs from the stored one stops the running bot and starts it again. Expect a short gap before it is back online, and the [Control panel](/custom-bot/control-panel) briefly reporting that your bot is starting.
{% endhint %}

<details>

<summary>Technical details</summary>

* Saving compares the stored config with the new one. A changed `status` or `activity` is pushed to the running bot fire and forget: the call is never queued, and a stopped bot never receives it.
* The bot applies the push in memory, and a job re-applies the presence from those same values every 5 minutes and on ready (`0 */5 * * * *`), so the change holds until the bot restarts.
* A changed token triggers a restart, which does nothing when the bot is stopped. When it is running, it stops the bot, waits for the pod to be gone, and starts it again, start pre-check included.
* Stored defaults (`packages/db/prisma/schema.prisma`): `status` `online`, activity `type` `4` (Custom), `state` `Counting everything!`, `name` empty.
* A starting pod is handed the config as `DISCORD_BOT_INSTANCE_BOT_STATUS` and `DISCORD_BOT_INSTANCE_BOT_PRESENCE_ACTIVITY`, so a restart reloads the presence from the database.
* Autosave fires 10 seconds after the last edit, and every edit re-arms the countdown.
* The bot profile row, the **Edit bot details** link and the invite button come from base64-decoding the first segment of the token to get the application id and fetching that Discord user, which is why a wrong or revoked token hides all three and leaves the bot listed as **Unknown bot {id}**, with the invite button disabled.
* The **URL** is not validated anywhere; it is handed to Discord as typed.
* Self-hosting: there is no dashboard for this. The presence comes from those same two variables in `.env`. That activity list may hold several entries: the first one is used, with a 10% chance of a random one at each 5-minute tick.

</details>


# Control panel

Start and stop your custom bot, read its status and resource usage, and delete it.

The **Control panel** card is where you turn your custom bot on and off and see how it is doing. You will find it on your bot's page in the [dashboard](https://membercounter.app/dashboard), under the **Bot configuration** card.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-6f84b4b432988365ad23d77e07c3c5daeb6c2ff1%2Fcustom-bot-control-panel.png?alt=media" alt="Control panel card"><figcaption><p>The <strong>Control panel</strong> card with the bot stopped: only the title and the power button</p></figcaption></figure>

## The power button

The power icon in the card header is the on/off switch. Press it to start a stopped bot, press it again to stop a running one. Its tooltip tells you the current state, including when a start or a stop is under way.

The card refreshes on its own, so you do not need to reload the page. Starting is not instant.

### When the start is refused

| Message                                              | What to do                                                                                                                                                                                                                                              |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The bot must have at least a token**               | Paste a token in [**Bot configuration**](/custom-bot/changing-bots-status) and save.                                                                                                                                                                    |
| **Bot failed to start (check the token or intents)** | Either the token is wrong (reset it and paste the new one) or the privileged intents are off. Both **PRESENCE INTENT** and **SERVER MEMBERS INTENT** must be enabled, see [Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account). |
| **Payment required**                                 | The subscription has expired. See [Managing your subscription](/custom-bot/managing-your-subscription).                                                                                                                                                 |

## Status and resource usage

While the bot runs, the card shows a sentence about its state and two bars, **CPU Usage** and **RAM Usage**. Both are percentages of the limit your subscription pays for, not raw figures. **RAM Usage** is the one that matters: memory is what your quota limits, and a bot that runs out of it is stopped.

The card also shows what your bot is doing on Discord right now:

* The Discord connection state. **Ready** means the bot is connected.
* The memory the bot is using, with its peak.
* How many users and servers the bot can see.

## Status messages

| Message                                                                                                             | What to do                                                                                                                                                                                                                                         |
| ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Your bot is online and running smoothly!                                                                            | Nothing. This is the healthy state.                                                                                                                                                                                                                |
| Your bot is being prepared to run. Please wait a moment.                                                            | Nothing. This is the normal first step after you press the power button.                                                                                                                                                                           |
| Your bot is restarting due to some internal issues. It should be back online shortly.                               | Wait. If it never settles on the running message, treat it as a crash loop and ask for help.                                                                                                                                                       |
| Your bot was stopped because it exceeded the memory limits of your current plan. Upgrade your plan to prevent this. | Raise your quota, see [Managing your subscription](/custom-bot/managing-your-subscription). If your server is far smaller than the member figure your quota advertises, contact support instead: capacity increases for misbehaving bots are free. |

Every other message is one for us. Most of them link to the [support server](https://membercounter.app/support) themselves; ask there and we will look into it.

## Deleting the bot

The **Delete bot** button sits below the cards. It asks you to confirm first.

{% hint style="danger" %}
Deleting is irreversible. It stops the bot, cancels the subscription and removes the configuration: token, status, activity, everything. Your counters and server settings are not touched, and the Discord application stays in your Developer Portal until you delete it there yourself.
{% endhint %}

If you only want the bot to stop for a while, press the power button. That is the only thing that stops a bot. **Pause** on the **Subscription** card does not stop it, see [Managing your subscription](/custom-bot/managing-your-subscription).

<details>

<summary>Technical details</summary>

* The card polls the bot status every second, and the power button polls it again on its own every 3 seconds.
* A start creates a Kubernetes StatefulSet named after the bot id, with one pod, `<botId>-0`; a stop deletes it and waits until the pod is gone. The container then has to be scheduled on a machine before the bot logs in to Discord and caches your servers.
* A start checks four things in order, and deploys nothing until all four pass: the subscription expiry date (`PAYMENT_REQUIRED`), whether a StatefulSet already exists, a token on the config (`BOT_TOKEN_MISSING`), and a real `client.login()` with GuildMembers, GuildPresences and GuildVoiceStates (`BOT_START_PRE_CHECK_FAILED`). The pre-check uses the same privileged intents the bot will run with, so a bot with the intents off cannot be started at all rather than starting and failing quietly.
* The status sentence is one of 13 pod states, listed in `packages/services/src/customBots/parsePodStatus.ts`: SCHEDULING, RUNNING, RESTARTING, TERMINATING, CRASH\_LOOP, OOM\_KILLED, EVICTED, FAILED, IMAGE\_PULL\_FAILED, NODE\_UNAVAILABLE, UNSCHEDULABLE, UNAVAILABLE, UNKNOWN. A pod whose status cannot be read yet shows the SCHEDULING sentence.
* The bars compare the pod's live metrics with the container limits. The memory in the stats panel is the bot process itself, `rss` with `maxrss` as the peak, so it does not match the **RAM Usage** bar, which measures the whole container.
* The server count gains an unavailable figure when Discord has not handed over some servers yet.
* The pod runs with `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` and `DISCORD_BOT_INSTANCE_IS_PREMIUM` forced to `true`, and `NODE_OPTIONS=--max-old-space-size` set to 75% of the container memory limit, the remaining 25% being for buffers, native modules and Discord.js overhead.
* Stopping is not only manual: a controller turns the desired state off for every bot whose subscriptions all expired more than 3 days ago, and then stops it.
* **Delete bot** stops the bot, cancels the subscription with no refund and deletes the row.
* Self-hosting: none of this exists. You start and stop the bot yourself, and the resource limits are whatever your machine gives it.

</details>


# Managing your subscription

Subscribe, upgrade, pause, resume or cancel, pay with PayPal or credit, and what happens when it expires.

A custom bot runs on our hosting, so it needs a subscription. Everything on this page lives in the **Subscription** card on your bot's page in the [dashboard](https://membercounter.app/dashboard).

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8f8a5577bfff07dd3dd9444fb01967e8ba2ca3ac%2Fcustom-bot-subscription-card.png?alt=media" alt="Subscription card"><figcaption><p>The <strong>Subscription</strong> card: quota picker, price, and the control buttons</p></figcaption></figure>

## What you are paying for

The price is set by the **quota** you pick with the minus and plus buttons. Every step of the picker adds \~50,000 members and **€5.00 per quarter**, and the subscription renews every 3 months. Counters and servers are never limited, size is.

{% hint style="info" %}
The member figure is a guideline, not a meter. What we actually limit is the memory your bot may use, which is why the [Control panel](/custom-bot/control-panel) shows a **RAM Usage** bar. If your bot misbehaves while you are well under the advertised figure, contact us: we raise the capacity for free.
{% endhint %}

## The buttons

| Button         | When it appears                                          | What it does                                                                  |
| -------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- |
| **Subscribe**  | No subscription yet, or the last one was cancelled.      | Opens the **Setup subscription** dialog.                                      |
| **Subscribed** | You already have exactly the quota shown.                | Nothing. It only tells you that you are set.                                  |
| **Upgrade**    | You moved the quota picker away from your current quota. | Opens the same dialog, to buy the new quota.                                  |
| **Pause**      | Only while the subscription is active.                   | Suspends billing. The bot keeps running.                                      |
| **Resume**     | Only while the subscription is suspended.                | Resumes a paused subscription.                                                |
| **Cancel**     | Whenever a subscription exists.                          | Ends the subscription. No refund, and the bot keeps running until it expires. |

The card header shows the expiry date, and tells you once it has passed.

## Subscribing

Pick your quota, then press **Subscribe**. In the **Setup subscription** dialog, tick the box agreeing to the Terms of Service, the Privacy Policy and the Acceptable use Policy, and the payment buttons become usable.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-2b46399cf56aab93d5c88f251616d29c80377b0a%2Fcustom-bot-setup-subscription-dialog.png?alt=media" alt="Setup subscription dialog"><figcaption><p>The <strong>Setup subscription</strong> dialog, with the agreement checkbox ticked and both payment methods shown</p></figcaption></figure>

There are exactly two payment methods.

* **PayPal.** Creates a recurring PayPal subscription.
* **Credit.** The **Subscribe using my credit** button appears only if your account has a credit balance, and you cannot use it when the balance is below the price.

Credit is a balance in euros attached to your account. It shows on your [account page](/using-the-dashboard/account) when it is not zero. Only staff can grant credit, in special cases. You cannot buy it. Refunds go back onto the balance.

Whichever method you pay with, a successful subscription also grants your account the [**Premium** badge](/contributing/badges).

## Upgrading

Move the quota picker and press **Upgrade**. Confirming the purchase does not edit your subscription, it replaces it: the old one is cancelled, your last payment on it is refunded in full, a new subscription is created at the new quota, and the billing cycle starts again from that moment. Downgrading works the same way. If the expiry date has already passed there is nothing left to refund, so changing quota after expiry refunds nothing.

## Pausing, resuming and cancelling

None of the three touches the bot itself, and cancelling refunds nothing. **Pause** in particular is a billing control, not an off switch: a paused bot carries on running until its expiry date passes. The only way to stop a bot is the power button in the [Control panel](/custom-bot/control-panel).

## Expiry and the grace period

When the expiry date passes, your bot is **not** switched off at once. It keeps running for a **grace period of 3 days**, and only then is it stopped automatically. But you cannot start it again: from the expiry date onward the power button fails with **Payment required** until you subscribe again.

If PayPal has not reported a next billing time yet, the subscription is created already expired, and it only becomes valid once PayPal confirms the payment to us.

## Messages you may see

| Message                                          | Meaning                                                                                                                                                                                                                                                                       |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Quota must be greater than zero**              | The quota sent was below 1. Pick at least one unit.                                                                                                                                                                                                                           |
| **Payment required**                             | You tried to start a bot whose subscription has expired. Subscribe again.                                                                                                                                                                                                     |
| **The subscription does not belong to this bot** | The subscription was not created for this bot, or not for the quota you picked. The bot gets no subscription. A PayPal subscription created for it is cancelled and its payment refunded once the payment reaches us, so check your PayPal activity before subscribing again. |

## Self-hosting instead

Self-hosting gives you the same capabilities for free. You provide the machine and run the bot yourself instead of paying us to run it. See [Self-hosting the bot](/self-hosting/self-hosting).

<details>

<summary>Technical details</summary>

* Price: `CUSTOM_BOT_QUOTA_UNIT_PRICE` is `500n` with `DEFAULT_CURRENCY` `EUR` and 2 decimals, so the total charged is 500 cents times the quota (`packages/common/src/Constants.ts`). The 3-month interval is hardcoded where the subscription is created, and PayPal bills against `PAYPAL_PRODUCT_PLAN_ID`.
* The quota picker stops at 1 and at `PAYPAL_CUSTOMBOTS_MAX_QUOTA` (50 in `.env.example`), so the dashboard's own ceiling is EUR 250.00 per quarter. The advertised member figure is quota times 50,000.
* What a unit buys in the cluster: CPU limit `quota × quotaMultiplier × K8S_CUSTOMBOTS_BASE_QUOTA_CPU` millicores and memory limit `quota × quotaMultiplier × K8S_CUSTOMBOTS_BASE_QUOTA_MEM` MiB (50 and 100 in `.env.example`, so 50m and 100 MiB per unit). `quotaMultiplier` is a per-bot float, 1 by default, that staff raise to grant capacity without charging for it.
* Upgrading cancels the old subscription with the refund flag on, then creates a new one (`SubscriptionService.subscribe`, `packages/services/src/customBots/subscription/SubscriptionService.ts`).
* Refund maths (`calculateRefund`): it returns the whole last payment while now sits between the payment date and the expiry date, and `0` outside that window, because the elapsed-over-total ratio is a BigInt division that truncates to zero. The refund is only attempted when the last payment used the same payment source as the subscription.
* **Cancel** passes no refund flag, so nothing is refunded there, ever.
* Renewal: `credit-subscription-billing` charges every active credit subscription whose `nextBillingTime` has passed, pushes that date on by the interval, and extends the matching custom-bot subscription. `custom-bots-controller` then applies the grace period, `SUBSCRIPTION_GRACE_PERIOD = 3` days.
* `PaypalPaymentSource.subscribe` takes the expiry date from `billingInfo.nextBillingTime`. Its 3-day fallback for a missing billing time is dead code, so the subscription row is written already expired and only `PAYMENT.SALE.COMPLETED` renews it.
* `SUBSCRIPTION_UNMATCHED` is thrown after the PayPal subscription already exists, and nothing cancels it there: the cancelling and the refund happen when the payment arrives.
* Payment sources are the Prisma enum `PaymentSource { paypal, credit }`. There is no Stripe integration anywhere.
* Credit is a BigInt balance of euro cents on the user row, written only by the admin `user.update` mutation behind the ManageUsers permission. No code path turns a donation into credit by itself.
* PayPal webhook: `POST /api/paypal-events` verifies the signature and handles only `PAYMENT.SALE.COMPLETED` and `BILLING.SUBSCRIPTION.CANCELLED` (`apps/backend/src/routes/paypal.ts`). A payment is refunded automatically and its PayPal subscription cancelled when the subscription is not the one attached to this bot, when the bot no longer exists, when the currency is not EUR, or when the quantity is above the maximum quota. This happens on PayPal's side, so the dashboard shows no message.
* **Pause**, **Resume** and **Cancel** proxy straight to the PayPal API for a PayPal subscription, and the status is read live from PayPal, so the state in the dashboard is the state PayPal reports.
* The badge is granted in `SubscriptionService.subscribe` and never taken away, so it stays on the profile after the subscription ends.
* Other error strings: `The quota is too high` (`BOT_QUOTA_TOO_HIGH`, PayPal reported a quantity above the maximum), `This bot doesn't have a subscription` (`BOT_SUBSCRIPTION_MISSING`), `You don't have enough credit` (`NO_ENOUGH_CREDIT`), `This subscription is already active` (`BOT_SUBSCRIPTION_ALREADY_ACTIVE`, resuming one that is not paused) and `This subscription is already paused` (`BOT_SUBSCRIPTION_ALREADY_PAUSED`).

</details>


# Legacy Premium vs. Custom Bot

What the legacy premium bot was, how server upgrades still work, and why custom bots replaced it.

**Member Counter Premium**, the legacy premium bot, is a second bot donors could invite to a server. It refreshes channels every 5 minutes instead of every 10, as described in [How counters update](/using-the-dashboard/how-counters-update), and it runs the [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube) counters, which the public bot cannot. It has no privileged intents, so the [Members](/counters-reference/members) counter works exactly as on the public bot: approximate counts, and none of the finer filters.

Server upgrades are no longer sold. Only staff can grant them, and [custom bots](/custom-bot/how-to-get-a-custom-bot) took their place: a fully featured bot, with the privileged intents, running under a token you own. Legacy premium was never switched off, so if you already hold server upgrades, they still work exactly as they did.

## How server upgrades still work

A server upgrade is a one-shot credit that turns one Discord server into a premium server and lets you invite the legacy premium bot to it.

{% stepper %}
{% step %}

### Check your balance

Open your [account page](/using-the-dashboard/account). If you hold any upgrades you will see the **Available server upgrades** card, which lists your servers with an **Upgrade server** button on each. The button cannot be used on a server that is already upgraded, or when your balance is not enough.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-7ec681addea41daa2fab6b0024bdb6ffe7135d1a%2Faccount-server-upgrades.png?alt=media" alt="Available server upgrades card"><figcaption><p>The <strong>Available server upgrades</strong> card on the account page</p></figcaption></figure>
{% endstep %}

{% step %}

### Open the Upgrade server page

In the dashboard, pick the server and use **Upgrade server** in the server menu. That entry only shows while you still hold at least one upgrade, or if the server has been upgraded already.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-2d7bd99dff86865d3bc6241b7632d0a6a84e9431%2Fcustom-bot-upgrade-server-page.png?alt=media" alt="Upgrade server page"><figcaption><p>The <strong>Upgrade server</strong> page with the <strong>UPGRADE NOW</strong> button and its footnote</p></figcaption></figure>
{% endstep %}

{% step %}

### Press UPGRADE NOW

One upgrade is taken from your balance and the server is marked premium. Discord's invite page for the legacy premium bot opens in a new tab, and the dashboard takes you back to the server page.

{% hint style="warning" %}
A popup blocker can swallow that new tab, which makes it look as though the upgrade failed. It did not. Check the server menu for the **Server upgraded** badge and invite the bot from there.
{% endhint %}

{% hint style="danger" %}
This cannot be undone. The upgrade is spent, the server keeps its premium status, and you cannot move it to a different server afterwards.
{% endhint %}
{% endstep %}

{% step %}

### Invite the legacy premium bot

An **Invite premium bot** button appears once the server is upgraded, both on the server page and on that server's row on your account page. It opens Discord's invite flow for the legacy premium bot.
{% endstep %}
{% endstepper %}

The dashboard marks the [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube) counters as needing the premium bot or a custom bot, and those do work on the legacy premium bot. The finer [Members](/counters-reference/members) filters carry the same mark but fail on the legacy premium bot with "The bot does not have enough privileged intents to use this counter.", because it lacks the intents.

## Which one should you use?

|                | Legacy premium bot                                          | Custom bot                                         |
| -------------- | ----------------------------------------------------------- | -------------------------------------------------- |
| Who can get it | Only people who already hold server upgrades                | Anyone                                             |
| Scope          | One server per upgrade                                      | Every server you put the bot in                    |
| Branding       | Our name and avatar, shared with every other premium server | Your own application: your name, avatar and banner |
| Refresh        | Every 5 minutes                                             | Every 5 minutes                                    |
| Counters       | Twitch and YouTube, but not the finer Members filters       | All of them                                        |
| Cost           | An upgrade you already hold                                 | From EUR 5.00 per quarter                          |
| Reversible     | No. An upgrade is spent for good                            | Yes. Pause or cancel whenever you like             |

Server upgrades are not sold any more, so once yours are spent, [a custom bot](/custom-bot/how-to-get-a-custom-bot) is the way to get the same features. See [Managing your subscription](/custom-bot/managing-your-subscription) for what it costs.

If you would rather not pay at all, [run the bot on your own machine](/self-hosting/self-hosting) instead. A self-hosted bot has every feature a custom bot has.

<details>

<summary>Technical details</summary>

* Upgrading runs `GuildSettingsService.upgradeLegacyPremiumStatus` (`packages/services/src/guildSettings.ts`): it decrements the user's `legacyCredit` by 1 and then sets `legacyPremiumStatus` on the server's settings row. Two separate writes, no transaction. Upgrades are stored per user as `legacyCredit` and per server as `legacyPremiumStatus`, and are unrelated to the account credit that pays for a custom bot subscription, which is a balance in euro cents.
* Both refusals are thrown as plain errors rather than known errors, so the dashboard shows a generic unknown error with no detail: "Already upgraded to premium" when the server is premium already, and "User doesn't have any lagacy credit" (sic) when the balance is zero.
* After the mutation the page opens `/invite?guildId=<guildId>&botId=<DISCORD_LEGACY_PREMIUM_CLIENT_ID>` in a new tab and navigates back to the server page. The legacy application id is `688714342392135731` in `.env.example`.
* The 5 versus 10 minutes is one cron: `0 */5 * * * *` when the bot instance runs with `isPremium`, `0 */10 * * * *` otherwise. `isPremium` also picks the intents the client asks Discord for: GuildMembers and GuildPresences when the instance is premium and privileged, GuildVoiceStates when it is premium.
* Twitch and YouTube assert `isPremium` and throw `BOT_IS_NOT_PREMIUM`, "The bot is not a premium version and lacks certain features needed for this counter." The finer Members filters go through `throwAvailabiltyIssue`: a non-premium instance raises the same error, a premium instance missing the intents, which is what the legacy premium bot is, raises `BOT_HAS_NO_ENOUGH_PRIVILEGED_INTENTS`, "The bot does not have enough privileged intents to use this counter."
* With both bots in one server, only the instance advertising the highest compute priority updates the channels. Each bot re-advertises every 5 minutes and the advertisement expires after 15.
* Self-hosting: there are no server upgrades. A self-hosted bot is premium and privileged as long as `DISCORD_BOT_INSTANCE_IS_PREMIUM` and `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` stay at their default `true` in `.env` and both privileged intents are enabled in the Developer Portal.

</details>


# Self-hosting the bot

Run your own copy of Member Counter with Docker, on your own machine or your own server.

A self-hosted bot is a copy of Member Counter that you run yourself. It logs in with your own Discord bot token and stores its data on your machine. It also serves its own dashboard at `http://localhost:3000`.

It has every feature: channels update every 5 minutes and every counter works, as long as you turn on the privileged intents in the Discord Developer Portal.

## Self-hosting or a custom bot?

Self-hosting and a [custom bot](/custom-bot/custom-bot) give you the same bot. The difference is who runs the machine.

<table><thead><tr><th width="180">Self-hosted bot</th><th>Custom bot</th></tr></thead><tbody><tr><td>Free. You only pay for the hardware.</td><td>Paid, per quota unit per quarter.</td></tr><tr><td>You install, update and back it up.</td><td>We install, update and back it up.</td></tr><tr><td>Full control over your data and over every setting in <code>.env</code>.</td><td>Control panel with the settings that matter, on our hosting.</td></tr><tr><td>Runs as long as your machine is on.</td><td>Runs 24/7 on our infrastructure.</td></tr><tr><td>No official support beyond this guide.</td><td>Support from the team.</td></tr></tbody></table>

If you want the same result without the maintenance, read [How to get a custom bot](/custom-bot/how-to-get-a-custom-bot).

## Requirements

You will type commands in a terminal. Read this guide top to bottom before you start: every step depends on the one above it.

<table><thead><tr><th width="220">Software</th><th>Notes</th></tr></thead><tbody><tr><td>Docker, with Docker Compose v2.23.1 or newer</td><td><a href="https://docs.docker.com/engine/install/">Install Docker</a>. Check your version with <code>docker compose version</code>. Older versions cannot read the compose file.</td></tr><tr><td>git</td><td><a href="https://git-scm.com/download">Install git</a>.</td></tr><tr><td>A CPU with AVX support</td><td>The database runs <code>mongo:7</code>, and MongoDB 5.0 and newer need AVX. Older processors lack it, and some VMs and NAS boxes hide it from their guests. On Linux, check with <code>grep -o avx /proc/cpuinfo | head -1</code>.</td></tr></tbody></table>

{% hint style="warning" %}
Without AVX the `mongo` container exits the moment it starts, and the bot and the backend keep failing to reach the database.
{% endhint %}

## 1. Create your Discord application

Your bot needs its own application in the Discord Developer Portal. The steps are the same as for a custom bot:

{% content-ref url="/pages/Fx7QIcY4vrBPVjlfegeZ" %}
[Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account)
{% endcontent-ref %}

Come back with these four things:

* the **bot token**
* the **client ID**, on the OAuth2 tab
* the **client secret**
* a redirect URL added and saved on the OAuth2 tab: `http://localhost:3000/api/auth/callback`

{% hint style="warning" %}
Turn on the **PRESENCE INTENT** and the **SERVER MEMBERS INTENT** in the Developer Portal. Without both, your bot cannot log in. **MESSAGE CONTENT INTENT** is not used and should stay off.
{% endhint %}

## 2. Download Member Counter

{% tabs %}
{% tab title="HTTPS" %}

```bash
git clone --depth 1 -b master https://github.com/member-counter/bot.git
```

{% endtab %}

{% tab title="SSH" %}

```bash
git clone --depth 1 -b master git@github.com:member-counter/bot.git
```

{% endtab %}
{% endtabs %}

Then open the folder it created:

```bash
cd bot
```

{% hint style="info" %}
`-b master` matters: the repository's default branch is `dev`, which runs beta images that break more often.
{% endhint %}

## 3. Create your `.env` file

Every setting lives in one file. Copy the example:

```bash
cp .env.example .env
```

Open the new `.env` with any text editor. You might have to turn on hidden files in your file explorer to see it.

```bash
COOKIE_SECRET='8F2k9QvR7pLxW3mN5sT1yB6cH4dJ0zA2'
DISCORD_CLIENT_ID='1234567890123456789'
DISCORD_CLIENT_SECRET='aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY'
DISCORD_BOT_INSTANCE_TOKEN='MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.GaBcDe.FaKeToKeNfAkEtOkEnFaKeToKeN12'
WEBSITE_URL="http://localhost:3000"
DISCORD_OAUTH2_REDIRECT_URI='http://localhost:3000/api/auth/callback'
```

### The variables you must fill in

The file starts with the block of variables you have to fill in yourself. Set these six:

<table><thead><tr><th width="270">Variable</th><th>What to put in it</th></tr></thead><tbody><tr><td><code>COOKIE_SECRET</code></td><td>A random string of <strong>at least 32 characters</strong>. On macOS or Linux, <code>openssl rand -base64 32</code> generates one. Shorter values are rejected on start-up.</td></tr><tr><td><code>DISCORD_CLIENT_ID</code></td><td>Your application's client ID.</td></tr><tr><td><code>DISCORD_CLIENT_SECRET</code></td><td>Your application's client secret.</td></tr><tr><td><code>DISCORD_BOT_INSTANCE_TOKEN</code></td><td>Your bot token.</td></tr><tr><td><code>WEBSITE_URL</code></td><td>Where you will open the dashboard. Leave <code>http://localhost:3000</code> unless you put the bot behind a reverse proxy or a domain. <strong>Never add a trailing slash</strong>.</td></tr><tr><td><code>DISCORD_OAUTH2_REDIRECT_URI</code></td><td>Must match the redirect URL you saved in the Developer Portal character for character. Leave <code>http://localhost:3000/api/auth/callback</code> if you kept the default.</td></tr></tbody></table>

### Two more you should change

These sit in the second block, the one marked as not needing changes. They have values already, but those defaults are written for the public bot.

<table><thead><tr><th width="270">Variable</th><th>Why it matters</th></tr></thead><tbody><tr><td><code>PUBLIC_BOTS_IDS</code></td><td>Ships as <code>'["main", "premium", "beta"]'</code>. Your install runs a single bot with the id <code>main</code>, so the other two ids make your dashboard show two bots that are permanently offline. Set it to <code>PUBLIC_BOTS_IDS='["main"]'</code>.</td></tr><tr><td><code>MAIN_DISCORD_CLIENT_ID</code></td><td>Ships as <code>478567255198662656</code>, the official Member Counter application. <a href="/commands/invite">/invite</a> builds its link from this value, so until you change it, <code>/invite</code> on your own bot invites ours. Set it to the same value as your <code>DISCORD_CLIENT_ID</code>.</td></tr></tbody></table>

Everything else already works for a single bot on one machine. For extra counters (YouTube, Twitch, Memerator, currency rates), a custom domain, a reverse proxy or sharding, see the [configuration reference](/self-hosting/configuration).

## 4. Prepare the database

Start only the database services:

```bash
docker compose up -d mongo setup-mongo-rs redis
```

{% hint style="info" %}
`setup-mongo-rs` runs once and exits. Seeing it as `Exited (0)` in `docker compose ps -a` is the healthy outcome, not a failure.
{% endhint %}

Then create the collections and indexes, and apply the schema:

```bash
docker compose run --rm -T db-utils npm run db:migrate up
docker compose run --rm -T db-utils npm run db:push
```

Both commands print their progress and exit.

## 5. Start the bot for the first time

Start everything, keeping the bot's and the backend's output on screen:

```bash
docker compose up --attach bot --attach backend
```

A healthy first start ends with these lines:

```log
bot     | 2026-09-19T12:00:08.431Z - info - [bot: main (0)] - Bot ready
bot     | 2026-09-19T12:00:08.433Z - info - [bot: main (0)] - Logged in as My Counter#7391
bot     | 2026-09-19T12:00:08.434Z - info - [bot: main (0)] - Invite link: https://discord.com/oauth2/authorize?client_id=1234567890123456789&permissions=269569044&scope=bot
```

Keep the invite link for the next step. The `warn` line about `OPENEXCHANGERATESORG_KEY` is harmless.

{% hint style="info" %}
On a cold start the bot and the backend can print database connection errors and restart a few times, because they start before MongoDB is ready and recover on their own.
{% endhint %}

If the log keeps failing, read the error and check the previous steps:

| Error                   | Fix                                          |
| ----------------------- | -------------------------------------------- |
| Disallowed intents      | Turn on both privileged intents in step 1.   |
| Invalid token           | Check `DISCORD_BOT_INSTANCE_TOKEN`.          |
| Cookie secret too short | Make `COOKIE_SECRET` at least 32 characters. |

When the log looks right, stop it with <kbd>Ctrl</kbd> + <kbd>C</kbd> and start it again in the background, so it survives closing the terminal:

```bash
docker compose up -d
```

## 6. Invite your bot and open the dashboard

Open the invite link from the log in a browser and add the bot to your server. The link already carries the permissions the bot needs.

Then open your dashboard at <http://localhost:3000> and sign in with Discord.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-30365444130f1744a4dd29873b91c06a8e2436c7%2Fself-host-dashboard-first-login.png?alt=media" alt="The dashboard server picker after signing in with Discord"><figcaption><p>Your servers appear in the picker on the left</p></figcaption></figure>

From here the bot works like any other install. Continue with [Quick setup](/getting-started/quick-setup).

## Day-to-day commands

Run all of these from the `bot` folder.

<table><thead><tr><th width="330">Command</th><th>What it does</th></tr></thead><tbody><tr><td><code>docker compose ps</code></td><td>Shows which services are running.</td></tr><tr><td><code>docker compose logs -f bot</code></td><td>Follows the bot's log. Swap <code>bot</code> for <code>backend</code>, <code>frontend</code> or <code>website</code>.</td></tr><tr><td><code>docker compose restart bot</code></td><td>Restarts one service. It does <strong>not</strong> pick up <code>.env</code> changes; use <code>docker compose up -d</code> for that.</td></tr><tr><td><code>docker compose up -d</code></td><td>Starts everything in the background, and applies <code>.env</code> changes.</td></tr><tr><td><code>docker compose down</code></td><td>Stops and removes the containers.</td></tr></tbody></table>

{% hint style="success" %}
`docker compose down` does not delete your data. The database lives in the `storage/db` folder next to the compose file.
{% endhint %}

## Back up your data

Back up two things; everything else can be rebuilt:

* `storage/db` holds your whole database: server settings, templates and channel settings.
* `.env` holds your secrets and settings.

Run `docker compose down` before you copy `storage/db`, so you copy a database that nothing is writing to. To restore, put both back in place and run `docker compose up -d`. Redis is only a cache, so it needs no backup.

New versions ship as new container images. See [Updating a self-hosted bot](/self-hosting/updating-a-self-hosted-bot).

## Getting help

Self-hosting is not covered by official support. Ask the community in #support on the [support server](https://membercounter.app/support). Bring the output of `docker compose logs --tail 50 bot`, and never paste your bot token. To report a bug or improve the bot itself, see [How to contribute](/contributing/how-to-contribute).

<details>

<summary>Technical details</summary>

* The v2.23.1 floor comes from the inline `configs:` entry that carries the nginx configuration; older versions, including the standalone `docker-compose` v1, fail to parse it.
* Branch and image tags: pushes to `master` build the `:latest` images, pushes to `dev` build the `:beta` images, and the compose file on each branch pins the matching tags. The git submodules are only needed to build the dashboard image yourself.
* Update interval: the channel update job runs on the cron `0 */5 * * * *` when the instance is premium and `0 */10 * * * *` when it is not. `DISCORD_BOT_INSTANCE_IS_PREMIUM` ships as `true`.
* Intents: GuildMembers and GuildPresences are requested only when `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` and `DISCORD_BOT_INSTANCE_IS_PREMIUM` are both true, and GuildVoiceStates when the second is. With the portal toggles off, discord.js stops with `Privileged intent provided is not enabled or whitelisted.`
* Every service validates `.env` on start-up, prints `❌ Invalid environment variables:` with the failing keys and exits with `Invalid environment variables`, so a bad value shows up as a restart loop. `COOKIE_SECRET` signs the dashboard session cookies; `WEBSITE_URL` is the base URL and the allowed browser origin, which is why a trailing slash breaks it.
* Invite link: `permissions=269569044` is ViewChannel, ManageChannels, Connect, SendMessages, ReadMessageHistory, EmbedLinks, ManageRoles and BanMembers.
* The `deployCommands:` lines on every start are normal: the bot registers its slash commands before logging in, because `DISCORD_BOT_INSTANCE_DEPLOY_COMMANDS` ships as `true`.
* `OPENEXCHANGERATESORG_KEY` ships empty, so the log shows `OPENEXCHANGERATESORG_KEY is not set, all rates will be x1` and every rate stays at 1. No counter uses it; it only affects currency conversion on the donors and donations pages. The optional counter keys are `YOUTUBE_API_KEY`, `TWITCH_CLIENT_ID` with `TWITCH_CLIENT_SECRET`, and `MEMERATOR_API_KEY`.
* Log lines are `timestamp - level - [meta] - message`, and the bot's meta is `[bot: <DISCORD_BOT_INSTANCE_ID> (<DISCORD_BOT_INSTANCE_CHILD_ID>)]`. `LOG_LEVEL` takes `error`, `warn`, `info` (the default) or `debug`; set it in `.env` and run `docker compose up -d` again.
* Database: `mongo:7` runs with `--replSet rs0` and the bind mount `./storage/db:/data/db`; `setup-mongo-rs` runs `rs.initiate` with `restart: "no"` and is the only service that waits for MongoDB's healthcheck, which is why the bot and the backend can start first. `db:migrate up` runs mongo-migrate-ts and records what it applied in the `migrations_changelog` collection; `db:push` runs `prisma db push`.
* Only `website` publishes a port, `3000:80`. It is `nginx:latest`, configured from the inline compose config: it rewrites `/api/(.*)` to `backend:8080/$1` and serves everything else from `frontend`. Behind another proxy, raise `BACKEND_MAX_TRUSTED_PROXIES`; its default `1` covers the bundled nginx only. `rest-proxy` and `db-utils` ship with `deploy.replicas: 0`, and the proxy only matters when several bot processes share one token: scale it to 1 and set `DISCORD_BOT_INSTANCE_REST_PROXY_URL=http://rest-proxy:8765/api`.
* The dashboard image is a static build, so `VITE_SUPPORT_URL`, `VITE_BOT_DOCS_URL`, `VITE_BOT_REPO_URL`, `VITE_TRANSLATION_PLATFORM_URL` and `SITE_URL` are baked in when the image is built and editing them in `.env` changes nothing. `VITE_SUPPORT_URL` and `VITE_BOT_REPO_URL` must stay in `.env` anyway, because the bot requires them; `SITE_URL` ships commented out and is only read by the frontend build.

</details>


# Configuration reference

Every variable in the .env file of a self-hosted bot, grouped by what it does.

You configure a self-hosted bot with one file: `.env`, in the folder you cloned.

Start it by copying the example:

```bash
cp .env.example .env
```

The block at the top of `.env` is the only part you must fill in:

```bash
# You can use this .env.example to build a new `.env` file when you clone the repo.

####################################################################################
########### The following variables are mandatory to be filled in by you ###########
####################################################################################

# You can generate a secret via 'openssl rand -base64 32' on Unix, or a password generator (this must be at least 32 characters long)
COOKIE_SECRET='i need to be 32 characters long'

DISCORD_CLIENT_ID=''
DISCORD_CLIENT_SECRET=''
DISCORD_BOT_INSTANCE_TOKEN=''

# You might need to change this if you are serving the website through a proxy server or under other circumstances
# DO NOT ADD A TRAILING SLASH, THIS IS ALSO USED AS A BASE URL
WEBSITE_URL="http://localhost:3000"

# You might need to change this if you are serving the website through a proxy server or under other circumstances
DISCORD_OAUTH2_REDIRECT_URI='http://localhost:3000/api/auth/callback'
```

{% hint style="warning" %}
An empty value counts as **not set**, not as an empty string. Leave a variable you do not want at its example value: deleting the line only works when the variable has a default in code, and the [sharding](#sharding) ones do not.
{% endhint %}

Every service of the stack that needs it reads `.env` when it starts. After editing it, run `docker compose up -d` so Compose recreates the containers with the new values.

## Mandatory (you must set)

These six are the block at the top of `.env.example`. The stack does not start without them.

| Variable                      | Default                                   | Meaning                                                                                                                                                                 |
| ----------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `COOKIE_SECRET`               | `i need to be 32 characters long`         | Signs the dashboard login cookie. Must be at least 32 characters. The example value is one character short, so replace it. Generate one with `openssl rand -base64 32`. |
| `DISCORD_CLIENT_ID`           | empty                                     | The client ID of your Discord application.                                                                                                                              |
| `DISCORD_CLIENT_SECRET`       | empty                                     | The client secret of your Discord application.                                                                                                                          |
| `DISCORD_BOT_INSTANCE_TOKEN`  | empty                                     | The bot token of your Discord application.                                                                                                                              |
| `WEBSITE_URL`                 | `http://localhost:3000`                   | The address you serve the dashboard from. Full URL with **no trailing slash**.                                                                                          |
| `DISCORD_OAUTH2_REDIRECT_URI` | `http://localhost:3000/api/auth/callback` | The OAuth2 callback. It must match a redirect URL in the Developer Portal character for character.                                                                      |

The two URLs already work if you open the dashboard at `http://localhost:3000` on the same machine. Change them when you serve the dashboard from a domain; see [Reverse proxy](#reverse-proxy).

[Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account) shows where to get the client ID, the client secret and the token, and where to add the redirect URL.

## Mandatory but pre-filled

These are already filled in. Change `MAIN_DISCORD_CLIENT_ID` and `PUBLIC_BOTS_IDS`, and leave the rest.

| Variable                                     | Default                                                                    | Meaning                                                                                                                                                                                                                  |
| -------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `NODE_ENV`                                   | `production`                                                               | Run mode. Keep `production` unless you are developing the bot.                                                                                                                                                           |
| `DATABASE_URL`                               | `mongodb://mongo:27017/memberCounter?replicaSet=rs0&directConnection=true` | MongoDB connection string. The instance must run as a replica set; the bundled containers take care of that.                                                                                                             |
| `REDIS_URL`                                  | `redis://redis:6379`                                                       | Redis connection string. Redis is a cache; it holds no data you need to keep.                                                                                                                                            |
| `BACKEND_PORT`                               | `8080`                                                                     | Port the backend listens on inside its container. Not published to the host.                                                                                                                                             |
| `BACKEND_MAX_TRUSTED_PROXIES`                | `1`                                                                        | How many proxies sit in front of the backend. See [Reverse proxy](#reverse-proxy).                                                                                                                                       |
| `OFFICIAL_WEBSITE_URL`                       | `https://membercounter.app`                                                | The project website, shown by `/info`. No trailing slash.                                                                                                                                                                |
| `BDE_CALL_TIMEOUT`                           | `2000`                                                                     | Milliseconds the backend waits for an answer from a bot process. Raise it on a slow machine if the dashboard reports the bot as unreachable.                                                                             |
| `MAIN_DISCORD_CLIENT_ID`                     | `478567255198662656`                                                       | The application `/invite` links to. **Set this to the same value as your `DISCORD_CLIENT_ID`.** The default is the official Member Counter application, so an untouched install invites the public bot instead of yours. |
| `PUBLIC_BOTS_IDS`                            | `["main", "premium", "beta"]`                                              | Which bot instance IDs the dashboard shows a status panel for. **Set this to `["main"]`.** You run one instance, so the default leaves two dead bots on the page.                                                        |
| `DISCORD_BOT_INSTANCE_DEPLOY_COMMANDS`       | `true`                                                                     | `true` registers the slash commands globally, a server ID in quotes registers them in that server only, `false` registers nothing.                                                                                       |
| `DISCORD_BOT_INSTANCE_BOT_PRESENCE_ACTIVITY` | a Member Counter activity list                                             | A JSON array of Discord activity objects. The first one is shown most of the time, the rest appear rarely. The shipped list is Member Counter branded, so change it for your own bot.                                    |
| `DISCORD_BOT_INSTANCE_BOT_STATUS`            | `online`                                                                   | The bot's status dot: `online`, `idle`, `dnd` or `invisible`.                                                                                                                                                            |

The rest of the pre-filled block has its own section below: [Sharding](#sharding), [Feature flags](#feature-flags), [REST proxy](#rest-proxy) and [Frontend build-time variables](#frontend-build-time-variables).

## Optional counters and extra services

Each of these switches on one counter or one extra service. Leave the ones you do not use empty.

| Variable                   | Default | Meaning                                                                                                                                                                                                                          |
| -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `YOUTUBE_API_KEY`          | empty   | A YouTube Data API key, from [console.developers.google.com](https://console.developers.google.com). Required by the [YouTube counter](/counters-reference/youtube).                                                             |
| `TWITCH_CLIENT_ID`         | empty   | The client ID of a Twitch application, from [dev.twitch.tv/console/apps](https://dev.twitch.tv/console/apps). Required by the [Twitch counter](/counters-reference/twitch).                                                      |
| `TWITCH_CLIENT_SECRET`     | empty   | The client secret of the same Twitch application.                                                                                                                                                                                |
| `MEMERATOR_API_KEY`        | empty   | A Memerator API key, from [memerator.me/api/auth](https://memerator.me/api/auth). Grant it the **Get Profiles - View Profiles** scope. Required by the [Memerator counter](/counters-reference/memerator).                       |
| `OPENEXCHANGERATESORG_KEY` | empty   | An [openexchangerates.org](https://openexchangerates.org) app ID. No counter uses it: it converts donation amounts to one currency on the donors and donations pages of your dashboard. Without it every currency counts as 1:1. |

The Twitch and YouTube counters also need `DISCORD_BOT_INSTANCE_IS_PREMIUM=true`, which is the default. With the key set but the flag off, both counters fail and the bot writes `ERROR!` into the channel.

`TOLGEE_API_KEY` also appears in `.env.example`. It is a tool for translators, not a runtime setting. Leave it empty; see [How to contribute](/contributing/how-to-contribute).

## Bot-list stats tokens

`DISCORD_BOT_INSTANCE_STATS_DBGG_TOKEN` (discord.bots.gg), `DISCORD_BOT_INSTANCE_STATS_DBL_TOKEN` (top.gg) and `DISCORD_BOT_INSTANCE_STATS_BFD_TOKEN` (Bots For Discord) post your server count to those listing sites. A private self-hosted bot is not listed on them, so leave all three empty and no request leaves your server.

## Logging

`LOG_LEVEL` sets how much every service prints: `error`, `warn`, `info` (the default) or `debug`. Use `debug` when you are chasing a problem. It prints several lines per channel on every update round, which is a lot of output on a busy server, so set it back to `info` afterwards.

## Sharding

Sharding splits a bot across several processes. One process is enough for a self-hosted bot until Discord requires more, and the shipped values are a working single-process setup. Keep all six in `.env` even when you run one process.

| Variable                                              | Default | Meaning                                                                                                                                                           |
| ----------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_INSTANCE_ID`                             | `main`  | Names the bot. Every process of the same bot must use the **same** value. Also what `PUBLIC_BOTS_IDS` refers to.                                                  |
| `DISCORD_BOT_INSTANCE_CHILD_ID`                       | `0`     | Names this process. Must be **different** for every process of the same bot.                                                                                      |
| `DISCORD_BOT_INSTANCE_SHARDING_SHARDS`                | `[0]`   | Which shards this process runs, as a JSON array of numbers, or a single number.                                                                                   |
| `DISCORD_BOT_INSTANCE_SHARDING_SHARD_COUNT`           | `1`     | How many shards the bot has in total, counting every process.                                                                                                     |
| `DISCORD_BOT_INSTANCE_SHARDING_SHARD_MAX_CONCURRENCY` | `1`     | How many shards may connect to Discord at the same time. Leave it at `1`.                                                                                         |
| `DISCORD_BOT_INSTANCE_COMPUTE_PRIORITY`               | `0`     | Decides which bot updates a server's channels when two bots share the same Redis and the same server. The highest number wins. Leave it at `0` with a single bot. |

Every process of the same bot must point at the **same** `REDIS_URL`. Several processes sharing one token also share one Discord request budget; see [REST proxy](#rest-proxy).

## Feature flags

Two flags decide which counters your bot can serve. Both ship as `true`, which is what makes a self-hosted bot equal to a [custom bot](/custom-bot/custom-bot).

| Variable                             | Default | Meaning                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` | `true`  | Together with `IS_PREMIUM`, requests the **Presence Intent** and **Server Members Intent**. They let the bot see individual members, which the [Members](/counters-reference/members) counter needs to filter by role, status, account type or game. Without them it can only report the total, online and offline counts.          |
| `DISCORD_BOT_INSTANCE_IS_PREMIUM`    | `true`  | Requests the voice state intent, which [Members](/counters-reference/members) needs to count members in a voice channel, and switches on the [Twitch](/counters-reference/twitch) and [YouTube](/counters-reference/youtube) counters. It also sets the update interval: `true` updates channels every 5 minutes, `false` every 10. |

{% hint style="warning" %}
Change the two flags together. The bot only asks for the privileged intents when **both** are `true`, while the Members counter picks its counting method from `DISCORD_BOT_INSTANCE_IS_PRIVILEGED` alone. `IS_PRIVILEGED=true` with `IS_PREMIUM=false` makes every Members counter report a far too low number, with no error to tell you. To run an unprivileged bot, set **both** to `false`.
{% endhint %}

{% hint style="danger" %}
Because both default to `true`, your bot asks Discord for the two privileged intents at login. If they are not switched on in the Developer Portal, the bot cannot log in. Turn on **Presence Intent** and **Server Members Intent** on the Bot tab, and leave **Message Content Intent** off.
{% endhint %}

## Reverse proxy

The stack publishes exactly one port: host port `3000`. Nothing else is reachable from outside the Docker network.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-0e8ed2f6e877c1929f74f5d23765abf6128e09e5%2Fself-host-request-path.svg?alt=media" alt="Diagram of the request path through the stack"><figcaption><p>One published port, two destinations behind it.</p></figcaption></figure>

To move the port, change the host side of the mapping in `docker-compose.yml`, for example `"8080:80"`.

To serve the dashboard from a domain, put your own proxy in front of host port `3000` and set three variables:

| Variable                      | Set it to                                                                                                                                                                                                                    |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WEBSITE_URL`                 | Your public address, for example `https://counters.example.com`. No trailing slash. The backend only accepts browser requests from this origin and sends you back here after login, so a wrong value logs you out in a loop. |
| `DISCORD_OAUTH2_REDIRECT_URI` | The same public address plus `/api/auth/callback`. Add the identical string to the redirect URLs of your application in the Developer Portal.                                                                                |
| `BACKEND_MAX_TRUSTED_PROXIES` | The number of proxies in front of the backend. The bundled nginx is one, so `1` is right with no external proxy, and `2` with your own proxy in front of it. Add one for each extra hop.                                     |

Terminate HTTPS at your own proxy. The stack speaks plain HTTP on port 3000.

## REST proxy

Discord allows roughly 50 REST requests per second **per token**, but the bot enforces that limit **per process**. Several processes sharing one token each think they own the whole budget and overrun it together. `rest-proxy` is a small server that owns the token's whole budget and hands it out, so the limit is counted once.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-8d3dc730402fa8276b1f9e6a2116c47e7536ce5a%2Fself-host-rest-proxy.svg?alt=media" alt="Diagram of two bot processes sharing one REST proxy"><figcaption><p>One limiter for one token, instead of one limiter per process.</p></figcaption></figure>

You do not need it with a single bot process, so the service does not run until you ask for it. To turn it on, change the `rest-proxy` service in `docker-compose.yml` from `replicas: 0` to `replicas: 1`, then run `docker compose up -d`.

Edit the file rather than running `docker compose up -d --scale rest-proxy=1`. `--scale` applies to that one command, so the next plain `docker compose up -d` puts the service back to `0` and leaves the bot sending its requests to a container that no longer exists.

| Variable                               | Default       | Meaning                                                                                                                                                                           |
| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_INSTANCE_REST_PROXY_URL`  | commented out | Base URL of the proxy, `http://rest-proxy:8765/api`. When it is set, the bot sends every Discord request through the proxy and `DISCORD_BOT_INSTANCE_DISCORD_API_RPS` is ignored. |
| `DISCORD_BOT_INSTANCE_DISCORD_API_RPS` | `45`          | Requests per second one bot process allows itself when no proxy is configured. Only lower this if Discord rate limits you.                                                        |

## Frontend build-time variables

These set the links the dashboard shows. `SITE_URL` is used only when building the frontend, to write `sitemap.xml`, `robots.txt` and the hreflang tags.

| Variable                        | Default                                 |
| ------------------------------- | --------------------------------------- |
| `VITE_SUPPORT_URL`              | `https://discord.com/invite/g4MfV6N`    |
| `VITE_BOT_DOCS_URL`             | `https://docs.membercounter.app/`       |
| `VITE_BOT_REPO_URL`             | `https://github.com/member-counter/bot` |
| `VITE_TRANSLATION_PLATFORM_URL` | `https://tolgee.membercounter.app/`     |
| `SITE_URL`                      | commented out                           |

{% hint style="warning" %}
The dashboard is a static site, built before it is published. These five values are baked into the published frontend image, so **changing them in `.env` does not change the dashboard**. To change the links you must build the frontend image yourself.
{% endhint %}

Keep `VITE_BOT_REPO_URL` and `VITE_SUPPORT_URL` in `.env`. The bot process reads them at runtime and refuses to start without them.

### Building the frontend image yourself

The build never reads `.env`: every value is a Docker build argument, and the image takes two passes.

1. Clone the repository **with its git submodules**. The emoji picker's data lives in two submodules and the build fails without them. In a folder you already cloned, run `git submodule update --init --recursive` instead.

   ```bash
   git clone --recurse-submodules -b master https://github.com/member-counter/bot.git
   cd bot
   ```
2. Build the `builder` stage with your own values. Add `--build-arg SITE_URL=https://counters.example.com` too if you want `sitemap.xml` and `robots.txt`.

   ```bash
   docker build -f apps/frontend/Dockerfile --target builder \
     --platform linux/amd64 \
     --build-arg VITE_SUPPORT_URL=https://discord.gg/your-invite \
     --build-arg VITE_BOT_DOCS_URL=https://docs.example.com/ \
     --build-arg VITE_BOT_REPO_URL=https://github.com/you/your-fork \
     --build-arg VITE_TRANSLATION_PLATFORM_URL=https://translate.example.com/ \
     -t my-frontend-builder .
   ```
3. Build the image you will run, pointing it at the image from step 2.

   ```bash
   docker build -f apps/frontend/Dockerfile --target runner \
     --build-arg JS_SOURCE_IMAGE=my-frontend-builder \
     -t my-frontend .
   ```
4. Point the `frontend` service in `docker-compose.yml` at `my-frontend`, then run `docker compose up -d`.

Repeat both builds every time you change one of these values, and after every [update](/self-hosting/updating-a-self-hosted-bot): `docker compose pull` does not refresh your own image.

## Time zone

There is no time zone variable. The containers run in **UTC**, and that is the fallback the [Clock](/counters-reference/clock) and [Date](/counters-reference/date) counters use when no time zone is picked in the counter's options.

{% hint style="warning" %}
With no time zone picked, the dashboard's preview uses **your** time zone and the channel uses the bot's, so the two disagree by your offset from UTC. Pick a time zone in the counter and both agree, wherever the bot runs.
{% endhint %}

## Backups

The only state worth keeping is MongoDB's, stored at `./storage/db` inside the cloned folder. Redis holds cache only. To back up, stop the stack so Mongo is not written to mid-copy, copy the folder, and start again:

```bash
docker compose down
tar -czf member-counter-backup-$(date +%F).tar.gz storage/db .env
docker compose up -d
```

Back up `.env` with it. It holds your `COOKIE_SECRET`, and restoring the database without it logs everyone out.

To restore, stop the stack, replace `storage/db` with the backed-up copy, and start again. Take a backup before every update, as [Updating a self-hosted bot](/self-hosting/updating-a-self-hosted-bot) says.

{% hint style="danger" %}
Deleting `storage/db` deletes every server's counters, templates and settings. Nothing else has a copy.
{% endhint %}

If you are stuck, ask in the [support server](https://membercounter.app/support).

<details>

<summary>Technical details</summary>

* The `bot`, `backend`, `frontend`, `website`, `rest-proxy` and `db-utils` services load `.env` through `env_file: .env` in `docker-compose.yml`. The `mongo`, `setup-mongo-rs` and `redis` containers do not read it; their command line configures them.
* Each service validates `.env` at startup against its own env schema (`apps/backend/src/env.ts` and the equivalents under `apps/bot`, `packages/services` and `apps/rest-proxy`), which is where the defaults and the validation live. All set `emptyStringAsUndefined: true`, which is why an empty value counts as unset, and `WEBSITE_URL` and `DISCORD_OAUTH2_REDIRECT_URI` must parse as URLs.
* Built-in defaults in code, used when the variable is absent from `.env`: `BACKEND_PORT` 8080, `BDE_CALL_TIMEOUT` 2000 ms, `LOG_LEVEL` `info`, `OFFICIAL_WEBSITE_URL` `https://membercounter.app`, `REST_PROXY_PORT` 8765. The sharding variables have none, so they must stay in `.env`.
* The bot always requests the Guilds, GuildEmojisAndStickers and GuildModeration intents, and adds GuildMembers, GuildPresences and GuildVoiceStates from the two flags. Intents the Developer Portal has not switched on stop the bot at login with a disallowed intents error.
* The update round is a cron job: `0 */5 * * * *` when `IS_PREMIUM` is `true`, `0 */10 * * * *` when it is not.
* Log lines are `timestamp - level - [meta] - message`. The bot adds its instance: `bot | 2026-09-19T12:00:00.000Z - info - [bot: main (0)] - Bot ready`. Services with no metadata print empty brackets.
* On startup the bot logs `Invite link: …`, built from its own client ID and the permissions it needs, not from `MAIN_DISCORD_CLIENT_ID`.
* `DISCORD_BOT_INSTANCE_COMPUTE_PRIORITY`: each bot advertises its priority over the shared Redis every 5 minutes, and those keys expire after 15 minutes. Two bots with the same priority both write the channels. Processes of the same bot also throttle their gateway identifies through Redis, which is why they must share one `REDIS_URL`.
* The bot stats job runs hourly and logs `Sending bot stats...` and `Bot stats has been sent` even when the three listing-site tokens are empty; nothing is sent.
* Without `OPENEXCHANGERATESORG_KEY` the backend logs `OPENEXCHANGERATESORG_KEY is not set, all rates will be x1` at startup.
* With a Twitch or YouTube key set but `DISCORD_BOT_INSTANCE_IS_PREMIUM=false`, the counter's last error reads **The bot is not a premium version and lacks certain features needed for this counter.**
* The `website` service is an nginx mapping host `3000` to its own `80`; it rewrites `/api/(.*)` to `/$1` towards `backend:8080` and sends everything else to `frontend:80`. The backend also serves `/trpc` and `/health`, its own compose healthcheck.
* `BACKEND_MAX_TRUSTED_PROXIES` decides which entry of `X-Forwarded-For` the backend believes is the visitor. Too low and every visitor looks like your proxy; too high and a visitor can claim any address. `WEBSITE_URL` is the CORS origin.
* `rest-proxy` publishes no host port and needs only `DISCORD_BOT_INSTANCE_TOKEN` from the same `.env`. Run one per token, on the same machine as the bot processes that use it. Invalid requests count towards Discord's ban threshold of 10,000 per 10 minutes per IP address, which is what it avoids for multi-process tokens.
* The frontend image builds in two stages. The `runner` stage copies the compiled site from the image named by `JS_SOURCE_IMAGE`, which defaults to `scratch`, so a one-pass build fails with nothing to copy.
* `TOLGEE_API_KEY` is used only by the `tolgee:push` and `tolgee:pull` scripts of `apps/bot` and `apps/frontend`.
* Adding a `TZ` variable to the services, or running the bot outside Docker, makes that time zone the fallback instead of UTC. Counters with a time zone picked always use the picked one.

</details>


# Updating a self-hosted bot

Back up, pull the new images, run the database migrations, start again.

New versions of Member Counter ship as new container images.

Run the steps in order, from a terminal in the `bot` folder you created when you [set the bot up](/self-hosting/self-hosting). Your counters stop updating while the bot is down.

{% hint style="warning" %}
`git pull` on its own does **not** update your bot. Every service runs a published image, so without `docker compose pull` you recreate the containers from the image you already had.
{% endhint %}

## Update

{% stepper %}
{% step %}

### Stop the bot

```bash
docker compose down
```

This stops and removes the containers. Your database stays on disk in `storage/db`.
{% endstep %}

{% step %}

### Back up `storage/db` and `.env`

Copy both somewhere safe while nothing is running:

* `storage/db` holds your server settings, templates and channel settings.
* `.env` holds your token, secrets and settings.

A migration can fail and leave the database half-updated, and this backup is how you restore it. Redis holds only a cache, so it needs no backup.
{% endstep %}

{% step %}

### Download the new compose file

```bash
git pull
```

This refreshes `docker-compose.yml` and `.env.example`, which is how new services, new web server settings and new variables reach you.
{% endstep %}

{% step %}

### Pull the new images

Run `docker compose images` first and keep the output: those digests are the only way back to the version you have now. Then download the new version of every service:

```bash
docker compose pull
```

{% endstep %}

{% step %}

### Check for new settings

Compare the refreshed example file with your own:

```bash
diff .env.example .env
```

On Windows without `diff`, open both files side by side. Only add variables that appear in `.env.example` and are missing from `.env`; the lines that differ because you filled them in are supposed to differ. The meaning of every variable is in the [configuration reference](/self-hosting/configuration).
{% endstep %}

{% step %}

### Start the database services

```bash
docker compose up -d mongo setup-mongo-rs redis
```

The migrations need the database running, and nothing else.
{% endstep %}

{% step %}

### Apply the database migrations

```bash
docker compose run --rm -T db-utils npm run db:migrate up
docker compose run --rm -T db-utils npm run db:push
```

Both commands exit when they are done. If either one fails, stop here and ask in #support on the [support server](https://membercounter.app/support) before starting the bot, so your database stays in a state you can still restore.
{% endstep %}

{% step %}

### Start everything again

```bash
docker compose up -d
```

Check that the bot came back:

```bash
docker compose logs -f bot
```

You are looking for `Bot ready` and `Logged in as`. Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to stop following the log; the bot keeps running.
{% endstep %}

{% step %}

### Clean up the old images

```bash
docker image prune -f
```

Optional, and best left until you are sure the update worked. It removes every untagged image on this machine, not only Member Counter's, and those are the images you would roll back to.
{% endstep %}
{% endstepper %}

## If the update goes wrong

Rolling back takes two parts: the data and the code. Restoring the backup alone is not enough, because the tags in `docker-compose.yml` now point at the new images.

1. Stop everything:

   ```bash
   docker compose down
   ```
2. Put your backed-up `storage/db` and `.env` back in place.
3. Pin the previous images. Find the digest you wrote down before the pull, or list what is still on disk:

   ```bash
   docker image ls --digests --filter=reference="ghcr.io/member-counter/*"
   ```

   Then in `docker-compose.yml`, replace the tag with that digest, for every service you are rolling back:

   ```yaml
   image: ghcr.io/member-counter/bot@sha256:<old digest>
   ```
4. Start again:

   ```bash
   docker compose up -d
   ```

{% hint style="danger" %}
Skip step 3 and you run the new code against the restored old database. If you already ran `docker image prune -f`, the old images are gone and the only way back is to pull the digest again from the registry.
{% endhint %}

Then bring the failure to #support on the [support server](https://membercounter.app/support) with the output of `docker compose logs --tail 50 bot`. Never paste your bot token. A [custom bot](/custom-bot/custom-bot) is the same bot on our hosting, updated for you: see [How to get a custom bot](/custom-bot/how-to-get-a-custom-bot).

<details>

<summary>Technical details</summary>

* Nothing in the compose file is built from source: `frontend`, `backend`, `bot`, `rest-proxy` and `db-utils` run `ghcr.io/member-counter/*` images, and the rest run `nginx:latest`, `mongo:7` and `redis`. Pushes to `master` build the `:latest` images and pushes to `dev` build the `:beta` images, and the compose file on each branch pins its own tags, which is what `git pull` keeps in step with the images.
* The tags are pins, not digests, so `docker compose pull` moves them to the new images. The old images stay on disk untagged until they are pruned, and `docker compose up -d` starts the new ones even when you restored an old database.
* `db:migrate up` applies the migrations in `packages/db/src/migrations` and records each one it applied in the `migrations_changelog` collection, so a second run applies only what is missing. `db:push` runs `prisma db push`.
* The `db-utils` image idles on `tail -f /dev/null`, which is why the migrations run through `docker compose run`. `--rm` deletes the one-off container and `-T` drops the TTY, which is what makes both commands safe in a script.
* MongoDB is the only service with storage: the bind mount `./storage/db:/data/db`. Redis declares no volume, so its cache is rebuilt after the update.
* A new variable that you miss shows up as a restart loop: each service validates its environment on start-up, prints `❌ Invalid environment variables:` with the failing keys and exits with `Invalid environment variables`. `docker compose up -d` recreates only the containers whose image, environment or configuration changed, and `docker compose restart` picks up neither a new image nor a new `.env`.
* Counters resume on the next channel update tick, which runs on the cron `0 */5 * * * *` with the default settings. The bot also re-registers its slash commands on every start, because `DISCORD_BOT_INSTANCE_DEPLOY_COMMANDS` ships as `true`.

</details>


# How to contribute

Report bugs, send pull requests, translate the bot, donate, help with these docs, and set up a local development environment.

Most of the ways to help Member Counter need no code.

## GitHub

The bot, the dashboard and everything around them live in one repository: [member-counter/bot](https://github.com/member-counter/bot). For an idea, a suggestion or a bug, [open an issue](https://github.com/member-counter/bot/issues) and answer the questions in the template. Say which counter, which channel type and which bot you were using. Useful reports, ideas and merged pull requests can earn a [badge](/contributing/badges).

Pull requests go to the `dev` branch, not to `master`. [Development environment](#development-environment) below sets you up.

## Tolgee

The bot and the dashboard are translated by the community on [Tolgee](https://tolgee.membercounter.app/), no development environment needed. To sign up with Discord, press **Sign Up with OAuth2** on the [sign up page](https://tolgee.membercounter.app/sign_up). Translators get the Translator badge, see [Badges](/contributing/badges).

## Donations

There is no donate button. Ask in the [support server](https://membercounter.app/support) and staff will arrange the donation. The [donors page](https://membercounter.app/donors) lists the donations so far.

After the donation, staff add the Donor badge to your profile and put your name on the donors page, unless you ask to stay anonymous.

{% hint style="info" %}
Both are done by hand, so they do not appear the moment you pay.
{% endhint %}

## Contributing to the docs

These docs are their own repository, [member-counter/docs](https://github.com/member-counter/docs), published with GitBook. Typos, missing steps and outdated screenshots are all fair game for a pull request. Read `tools/docs-style.md` first. It sets how each kind of page is built. For an outdated screenshot, open an issue or say which image and what changed: the images are generated by a tool that needs the maintainer's test server, so staff regenerate them.

## Development environment

This section gets the bot and the dashboard running on your machine. To run the bot for your own server, you want [self-hosting](/self-hosting/self-hosting) instead, which uses prebuilt images and needs none of this. You need [Git](https://git-scm.com/downloads), [Node.js 22](https://nodejs.org/en/download/) and [Docker](https://www.docker.com/get-started). pnpm is pinned in the repository, so `corepack enable` picks up the right version.

```bash
git clone -b dev git@github.com:member-counter/bot.git member-counter-bot
cd member-counter-bot
corepack enable
pnpm install
```

You also need your own Discord application. Never develop against the token of a bot that is in real servers. [Creating a Discord bot account](/custom-bot/creating-a-discord-bot-account) walks through the Developer Portal.

Copy `.env.example` to `.env` and set:

| Variable                                                                   | Value                                                                                          |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `COOKIE_SECRET`                                                            | Anything with at least 32 characters. The example value is one character short, so replace it. |
| `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_INSTANCE_TOKEN` | The credentials of your development application.                                               |
| `DATABASE_URL`                                                             | `mongodb://localhost:27017/memberCounter?replicaSet=rs0&directConnection=true`                 |
| `REDIS_URL`                                                                | `redis://localhost:6379`                                                                       |
| `NODE_ENV`                                                                 | `development`                                                                                  |

Also register `http://localhost:3000/api/auth/callback`, the default `DISCORD_OAUTH2_REDIRECT_URI`, as an **OAuth2 redirect URL** in the Developer Portal.

`.env` is not hot-reloaded. After you change it, stop the app and start it again.

Then start everything and open `http://localhost:3000`:

```bash
pnpm dev:docker:up   # MongoDB, Redis and the helper containers
pnpm db:migrate up   # first time only: create the collections and indexes
pnpm db:push
pnpm dev             # bot, backend and frontend, in watch mode
# pnpm dev:docker:down stops the containers when you are done
```

### Open a pull request

Run the checks before you push:

```bash
pnpm format:fix && pnpm lint && pnpm typecheck
```

Then [create a pull request](https://github.com/member-counter/bot/pulls) against the `dev` branch.

<details>

<summary>Technical details</summary>

* `dev` is the working branch and builds the `:beta` images that self-hosters can opt into. `master` is the released one and builds the `:latest` images.
* `.nvmrc` pins Node `22.22`, and the repository requires at least `22.22.0`. `packageManager` pins `pnpm@9.15.5`, and `.npmrc` sets `node-linker=hoisted`.
* The development MongoDB runs as a single-node replica set named `rs0`, initiated once by the `setup-mongo-rs` container, which is why `DATABASE_URL` carries `replicaSet=rs0&directConnection=true`. Its data lives in `./storage/db`.
* The database and Redis URLs differ from the self-hosting ones on purpose: in development the services are reached from your machine on `localhost`, not from inside the Docker network.
* Other local addresses: `http://localhost:5173` the frontend dev server, `http://localhost:8080` the backend, `http://localhost:3001` mongo-express, `http://localhost:3002` RedisInsight, with the local Redis already registered in it.
* Port 3000 is an nginx container that proxies `/` to the frontend and `/api` to the backend, stripping the `/api` prefix. It answers with a gateway error whenever `pnpm dev` is not running. Use it for normal work anyway: Discord OAuth2 only comes back to the redirect URL you registered, and that is the port 3000 one.
* nginx reaches the dev servers through `host.docker.internal`. Docker Desktop resolves that name on its own, but a plain Docker on Linux does not: add `extra_hosts: ["host.docker.internal:host-gateway"]` to the `website` service in `docker-compose.dev.yml`, or port 3000 will never connect.
* `pnpm install` also runs `sherif` over the workspace through the `postinstall` script, which catches mismatched dependency versions between packages.
* Screenshots: one JSON spec per image in `tools/screenshots/specs/`; run `pnpm shoot <name>` from `tools/screenshots/`. It needs `env.json` with the test server ids and a logged-in browser profile (`tools/screenshots/README.md`).

</details>


# Badges

Small marks on your profile that record what you have done for Member Counter.

A badge lives on your Member Counter profile, not on a server. Badges are cosmetic, they change nothing the bot does for your server.

## The badges

The bot shows them in this order.

| Icon | Name           | How you get it                                                                        |
| ---- | -------------- | ------------------------------------------------------------------------------------- |
| ❤️   | Donor          | Donate to the project. Staff add the badge by hand.                                   |
| 💎   | Premium        | Granted automatically when a custom bot subscription is set up.                       |
| 🧪   | Beta Tester    | Granted by staff for testing a build before its release.                              |
| 🌎   | Translator     | Translate the bot on [Tolgee](https://tolgee.membercounter.app/).                     |
| 💻   | Contributor    | Get a pull request merged in the [repository](https://github.com/member-counter/bot). |
| 🧠   | Big Brain      | Suggest an idea that we end up building.                                              |
| 🐛   | Bug Catcher    | Report a bug that turns out to be real.                                               |
| 🐱   | ???            | ???                                                                                   |
| 🧬   | Folding @ Home | Granted by staff for folding for the Member Counter team.                             |

[How to contribute](/contributing/how-to-contribute) explains translating, code and donations in full.

## Where badges show

On the [account page](/using-the-dashboard/account), your badges sit under your username. Hover one to read its description.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-cd81f570c744b183397d8c8f661c4afebbae0443%2Faccount-badges.png?alt=media" alt="Badges on the account page"><figcaption><p>Your badges on the account page, with one description shown</p></figcaption></figure>

In Discord, the [`/profile`](/commands/profile) command shows them in a **Badges** field. Run it on yourself, or pass a user to see someone else's badges. You can also right-click a member and choose **Apps** → **Profile**.

<figure><img src="https://1582170640-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lu2S5r7lKtu4IK5ucu9%2Fuploads%2Fgit-blob-288f3d0bc5aed0ba2fdffe5f4ca62e53cea0ce33%2Fcommand-profile-badges.png?alt=media" alt="The Badges field of the /profile reply"><figcaption><p>The reply of <code>/profile</code>, with the <strong>Badges</strong> field</p></figcaption></figure>

A user who holds no badges gets an empty **Badges** field, and no badges on the account page.

{% hint style="danger" %}
**Delete data** in the reply of `/profile` deletes your Member Counter profile, badges included. There is no way to get them back other than earning them again.
{% endhint %}

## Roles in the support server

Every badge has a matching role in the [support server](https://membercounter.app/support), and the two stay in sync on their own, in both directions. A badge you hold gets you its role, and a role you are given gets you its badge. When a matching role is taken from you, the badge goes with it. Other roles have nothing to do with badges.

<details>

<summary>Technical details</summary>

* Badges are one bitfield on the user record, one bit per badge. The list, its order and the emoji: `packages/common/src/UserBadges.ts`.
* Staff pick badges by name, and the names differ between tools: 🐱 is "Pat Pat" in the admin panel and "PatPat" in `/associate`, 🧬 is "folding\@home" and "Folding @ Home". Users only ever see the emoji and its description. 🐱 is an easter egg.
* 💎 Premium is granted to the account that owns the bot the moment a [custom bot](/custom-bot/how-to-get-a-custom-bot) subscription is set up. The bot never takes it away, so it stays on the profile after the subscription ends.
* The role-to-badge pairs are set once by staff with `/associate <role> <badge>` in the support server; everything after that is automatic. It needs Manage Roles, runs only inside a server, and holds one badge per role, so running it again on the same role replaces the mapping.
* The badge-to-role sweep runs when a member joins, over all members whenever the support server becomes available to the bot, and at the end of `/associate`, so an existing member does not have to leave and rejoin. It only adds roles, so a badge removed from a profile leaves its role in place, and it skips members who have no Member Counter profile.
* The other direction runs on every member update in the support server: the badges of the roles just added are set, the badges of the roles just removed are cleared.
* **Delete data** shows on your own profile and to staff holding Manage Users, **More options** to staff holding Manage Users or See Users. `/profile` on someone who has never used the bot or the dashboard answers "User not found." (`knownErrors.USER_NOT_FOUND`).

</details>


