Brazil's biggest MU Online portal — since 2003
Tutorial Intermediate Website

How to integrate Discord (event webhook) into your MU server

Connect your MU Online server to Discord via webhooks to announce invasions, resets, new registrations, and server status automatically, with pretty embeds, rate limiting, and a message queue.

GA Gabriel · Updated on Jul 14, 2025 · ⏱ 23 min read
Quick answer

Integrating your MU Online server with Discord turns the community channel into a living board: every announced invasion, every celebrated reset, every newly registered player shows up automatically, without anyone typing a thing. The most direct and robust way to do this is with webhooks — URLs tha

Integrating your MU Online server with Discord turns the community channel into a living board: every announced invasion, every celebrated reset, every newly registered player shows up automatically, without anyone typing a thing. The most direct and robust way to do this is with webhooks — URLs that receive a JSON POST and publish the message in the channel. In this tutorial you'll create the webhook, build a reusable PHP library with embeds, wire the triggers to real server events, and protect everything with a queue and rate limiting. The concrete values (colors, times, database fields) appear as examples and vary by version.

Prerequisites

This guide assumes a working server with a PHP site. If you're still building the base, see how to create a MU Online server and then come back to plug in Discord.

RequirementDetail (example — varies by version)
Discord serverA server where you have permission to manage webhooks
Announcements channelE.g. #anuncios or #eventos
PHP7.4+ with cURL enabled
Database accessRead the MU database to detect events
Cron or schedulerTo check for events periodically

The only non-obvious requirement is cURL enabled in PHP, since that's what we use to POST over HTTPS to Discord. Confirm with php -m | grep curl on Linux or by checking the extension in php.ini on Windows.

Step 1 — Create the webhook in Discord

The webhook is created inside Discord itself, not in code:

  1. Open the settings of the desired channel (the gear next to the channel name).
  2. Go to Integrations and then Webhooks.
  3. Click New Webhook, give it a name (e.g. "MU Server") and choose the channel.
  4. Click Copy Webhook URL. It has the format https://discord.com/api/webhooks/ID/TOKEN.
  5. Keep that URL as a secret — anyone who has it can post in your channel.

Treat the webhook URL like a password. It should not appear in versioned code, in browser JavaScript, or in a public repository. We'll keep it in a protected configuration file.

Step 2 — Keep the configuration outside the code

Create a config/discord.php file outside the public root, or at least protected from direct access. It holds the URL and general parameters:

<?php
// config/discord.php  (keep out of version control)

return [
    // URL copied from Discord — NEVER expose it publicly
    'webhook_url' => 'https://discord.com/api/webhooks/ID/TOKEN',

    // Default name and avatar shown by the webhook messages
    'username'    => 'MU Server',
    'avatar_url'  => 'https://yoursite.com/img/logo-discord.png',

    // Colors by event type (decimal integer — see Step 4)
    'cores' => [
        'invasao'  => 0xE67E22, // orange
        'reset'    => 0x9B59B6, // purple
        'registro' => 0x2ECC71, // green
        'status'   => 0x3498DB, // blue
        'alerta'   => 0xE74C3C, // red
    ],
];

Step 3 — PHP library to send to the webhook

Now the centerpiece: a function that does the POST with cURL, respects a timeout, and handles the rate limit (HTTP 429). Save it in lib/discord.php:

<?php
// lib/discord.php

/**
 * Sends a payload to the Discord webhook.
 * Returns the HTTP code; handles 429 (rate limit) by waiting the retry_after.
 */
function discordEnviar(array $payload, int $tentativas = 3): int {
    $cfg = require __DIR__ . '/../config/discord.php';

    $payload['username']   = $payload['username']   ?? $cfg['username'];
    $payload['avatar_url'] = $payload['avatar_url'] ?? $cfg['avatar_url'];

    $json = json_encode($payload, JSON_UNESCAPED_UNICODE);

    for ($i = 0; $i < $tentativas; $i++) {
        $ch = curl_init($cfg['webhook_url']);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $json,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 8,
            CURLOPT_CONNECTTIMEOUT => 4,
        ]);
        $resposta = curl_exec($ch);
        $codigo   = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        // 429 = rate limit: Discord tells you how long to wait
        if ($codigo === 429) {
            $dados = json_decode($resposta, true);
            $espera = (float) ($dados['retry_after'] ?? 1);
            usleep((int) ($espera * 1_000_000) + 100_000);
            continue; // try again
        }

        // 2xx = success (204 is the most common for a webhook)
        if ($codigo >= 200 && $codigo < 300) {
            return $codigo;
        }

        // another error: short pause and a new attempt
        usleep(500_000);
    }

    return $codigo ?? 0;
}

Handling HTTP 429 is what keeps your integration from getting blocked. When you exceed the limit, Discord responds with retry_after in seconds — the function waits that long and tries again, instead of hammering the endpoint.

Step 4 — Build pretty embeds

A plain text message does the job, but an embed — with color, title, thumbnail, and fields — gives a professional look. The color is a decimal integer (the hex 0xE67E22 is orange). Let's create a builder:

<?php
// lib/discord.php (continued)

/**
 * Builds a standardized embed for server events.
 */
function discordEmbed(
    string $titulo,
    string $descricao,
    string $tipo = 'status',
    array  $campos = [],
    ?string $thumb = null
): array {
    $cfg = require __DIR__ . '/../config/discord.php';
    $cor = $cfg['cores'][$tipo] ?? 0x95A5A6;

    $embed = [
        'title'       => $titulo,
        'description' => $descricao,
        'color'       => $cor,
        'timestamp'   => date('c'), // ISO 8601, Discord formats the time
        'footer'      => ['text' => 'MU Server · Automatic system'],
    ];

    if ($thumb) {
        $embed['thumbnail'] = ['url' => $thumb];
    }

    // Fields appear in a grid: [['name'=>..,'value'=>..,'inline'=>true], ...]
    if ($campos) {
        $embed['fields'] = $campos;
    }

    return ['embeds' => [$embed]];
}

With that, sending an announcement becomes a single line:

<?php
require_once __DIR__ . '/lib/discord.php';

discordEnviar(discordEmbed(
    '⚔️ Golden Dragon Invasion!',
    'The invasion has started in **Lorencia**. Rush over to grab the drops!',
    'invasao',
    [
        ['name' => 'Map',      'value' => 'Lorencia', 'inline' => true],
        ['name' => 'Duration', 'value' => '20 min',   'inline' => true],
    ],
    'https://yoursite.com/img/golden.png'
));

Step 5 — Detect server events

The most important point of the architecture: you don't modify the C++ GameServer. Instead, the PHP backend detects the event and fires the webhook. There are three detection patterns, from the simplest to the most integrated:

PatternHow it worksWhen to use
ScheduledCron fires at the events' fixed timesInvasions and events at known times
Database pollingCron reads the database and detects changes (new resets, registrations)Resets, new registrations, milestones
Internal endpointThe site calls the webhook when an action happens on the site itselfAccount registration, VIP purchase

Example: announcing invasions by time

If your events occur at fixed times (which vary by version and by your config), a cron every minute checks whether it's time to announce:

<?php
// cron/anunciar-invasoes.php  (run via cron every 1 minute)
require_once __DIR__ . '/../lib/discord.php';

// EXAMPLE times — adjust to your GameServer's
$agenda = [
    '20:00' => ['nome' => 'Golden Invasion', 'mapa' => 'Devias'],
    '22:00' => ['nome' => 'Golden Invasion', 'mapa' => 'Noria'],
    '21:00' => ['nome' => 'Red Dragon',      'mapa' => 'Atlans'],
];

$horaAtual = date('H:i');
if (isset($agenda[$horaAtual])) {
    $ev = $agenda[$horaAtual];
    discordEnviar(discordEmbed(
        '🐉 ' . $ev['nome'] . ' has started!',
        'Invasion active in **' . $ev['mapa'] . '**. Happy hunting!',
        'invasao',
        [['name' => 'Map', 'value' => $ev['mapa'], 'inline' => true]]
    ));
}

Example: announcing new resets by polling

To celebrate when someone resets, store the last reset already announced and check the database periodically:

<?php
// cron/anunciar-resets.php  (run via cron every 2 minutes)
require_once __DIR__ . '/../lib/discord.php';
require_once __DIR__ . '/../lib/db.php'; // getMuDB()

$marcador = sys_get_temp_dir() . '/ultimo_reset_id.txt';
$ultimoId = is_file($marcador) ? (int) file_get_contents($marcador) : 0;

$conn = getMuDB();

// The reset-log table/column VARY BY VERSION — adjust to your schema.
// Here we assume a ResetLog(id, Name, Resets, DataReset) table.
$sql = "SELECT TOP 10 id, Name, Resets
        FROM ResetLog
        WHERE id > ?
        ORDER BY id ASC";
$stmt = sqlsrv_query($conn, $sql, [[$ultimoId, SQLSRV_PARAM_IN]]);

$maiorId = $ultimoId;
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
    discordEnviar(discordEmbed(
        '🔄 New Reset!',
        '**' . $row['Name'] . '** reached reset **' . $row['Resets'] . '**!',
        'reset',
        [['name' => 'Resets', 'value' => (string) $row['Resets'], 'inline' => true]]
    ));
    $maiorId = max($maiorId, (int) $row['id']);
    usleep(300_000); // small breather between messages (rate limit)
}

file_put_contents($marcador, (string) $maiorId);

The marker file is what avoids announcing the same reset twice. Without it, every cron run would resend everything.

Example: announcing a new registration from the site

When the event happens on the site itself (account registration), fire it right in the flow:

<?php
// inside your registration process, after successfully creating the account
require_once __DIR__ . '/lib/discord.php';

discordEnviar(discordEmbed(
    '🎉 New player!',
    'Welcome to the server! The account count grows every day.',
    'registro'
));

Step 6 — A queue to respect the rate limit

If several events fire at the same time (ten resets in the same minute), sending everything at once blows the rate limit. The professional solution is a queue: events go into a table and a single worker sends them with spacing.

-- Discord message queue (MySQL example)
CREATE TABLE discord_fila (
    id        INT AUTO_INCREMENT PRIMARY KEY,
    payload   TEXT      NOT NULL,     -- embed JSON
    status    TINYINT   NOT NULL DEFAULT 0,  -- 0=pending,1=sent,2=error
    criado_em DATETIME  NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_status (status, id)
);
<?php
// lib/discord-fila.php

// Enqueue (call this instead of discordEnviar when there's volume)
function discordEnfileirar(array $payload): void {
    $pdo = getSiteDB();
    $pdo->prepare("INSERT INTO discord_fila (payload) VALUES (:p)")
        ->execute([':p' => json_encode($payload, JSON_UNESCAPED_UNICODE)]);
}

// Worker (run via cron every 1 minute) — sends with spacing
function discordProcessarFila(int $maxPorRodada = 8): void {
    $pdo = getSiteDB();
    $itens = $pdo->query(
        "SELECT id, payload FROM discord_fila WHERE status = 0 ORDER BY id ASC LIMIT $maxPorRodada"
    )->fetchAll(PDO::FETCH_ASSOC);

    foreach ($itens as $item) {
        $codigo = discordEnviar(json_decode($item['payload'], true));
        $novoStatus = ($codigo >= 200 && $codigo < 300) ? 1 : 2;
        $pdo->prepare("UPDATE discord_fila SET status = :s WHERE id = :id")
            ->execute([':s' => $novoStatus, ':id' => $item['id']]);
        usleep(400_000); // ~0.4s between sends so as not to blow the limit
    }
}

With the queue, even a flood of events becomes a controlled flow of messages, and none is lost to an HTTP 429.

Step 7 — Test the integration

Before leaving it running, test manually with a simple script:

<?php
// teste-discord.php
require_once __DIR__ . '/lib/discord.php';

$codigo = discordEnviar(discordEmbed(
    '✅ Integration test',
    'If you are seeing this in the channel, the webhook is working!',
    'status'
));

echo $codigo === 204 || ($codigo >= 200 && $codigo < 300)
    ? "OK — message sent (HTTP $codigo)\n"
    : "Failed — HTTP $codigo\n";

Run it with php teste-discord.php. An HTTP 204 means success (Discord returns no body on a successful webhook, which is why it's 204 and not 200).

Common errors and fixes

SymptomLikely causeFix
Nothing appears in the channelWrong webhook URL or deleted channelRecopy the URL in the channel's integrations
HTTP 401/403Invalid or revoked webhook tokenGenerate a new webhook and update the config
Constant HTTP 429Too many messages in a short timeImplement the queue and respect retry_after
Broken accentsJSON without JSON_UNESCAPED_UNICODEUse the flag when encoding the payload
Embed without colorColor sent as a hex stringSend the color as a decimal integer
Reset announced twiceLast-ID marker not writtenCheck the write to the marker file/table
cURL fails silentlycURL extension disabledEnable curl in php.ini

Security and best practices

  • The webhook URL is a secret. Out of versioned code, out of the browser, out of public repositories. Anyone who has it posts in your channel.
  • Always handle the 429. Respect Discord's retry_after; never resend in a blind loop.
  • Decouple the game from Discord. Detect events via the database/site, not by modifying the GameServer.
  • Use a queue under volume. Several simultaneous events require spacing between sends.
  • Idempotency in the polls. A "last processed" marker avoids duplicate announcements.
  • Short cURL timeout. A slow Discord must not freeze your cron or your account registration.

Launch checklist

  • Webhook created and URL copied securely
  • Discord config outside versioned code
  • cURL enabled in PHP confirmed
  • Send function handling HTTP 429 with retry_after
  • Embeds with a color per event type
  • Invasion cron at the GameServer's real times
  • Reset polling with a last-ID marker
  • Registration trigger wired into the site's flow
  • Queue implemented for event spikes
  • Queue worker running via cron with spacing
  • Manual test returning HTTP 204
  • Real test: force an event and confirm the message in the channel

With the integration in place, Discord becomes an automatic extension of the server. Every invasion becomes a call to action, every reset becomes a public celebration, and every new player is welcomed by the community — all with no manual effort, with code that respects Discord's limits and never loses a message.

Frequently asked questions

Webhook or bot: which one should I use to announce events?

For sending automatic event messages, the webhook is simpler and doesn't require keeping a bot online. The bot is only needed when you need Discord to react to commands or read messages. For invasion, reset, and status announcements, the webhook does the job with far less effort.

Can the webhook URL live in the site's code?

It should live outside the versioned code, in a protected configuration file or an environment variable. Anyone who has the URL can post in your channel, so treat it as a secret. Never expose it in browser JavaScript or in a public repository.

How often can I send messages to the webhook?

Discord applies a rate limit. As a reference, avoid exceeding about 5 requests within a few seconds on the same webhook; the exact limits vary, and Discord returns HTTP 429 with a wait time when you exceed them. Implement a queue and respect the retry header.

How do I make the game server fire the webhook if it's in C++?

You rarely change the GameServer directly. The standard pattern is for the site/backend to detect the event (by reading the database, a log, or an endpoint) and fire the webhook in PHP. That decouples the game from Discord and avoids touching the server core.

Can I send embeds with color, image, and fields?

Yes. The webhook accepts an embeds array with a title, description, color (decimal integer), thumbnail, fields, and footer. That's what gives the announcement a professional look instead of plain text.

GA
Guides & builds editor

Gabriel covers gameplay, class builds, PvP and progression. He tests every strategy on a live server before publishing.

Keep reading

Related articles