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

How to integrate donations with Mercado Pago on your MU server website

Integrate Mercado Pago into your MU Online server website to receive donations via Pix and card, with payment preferences, a confirmation webhook and automatic credit delivery in a secure, idempotent way.

BR Bruno · Updated on Jul 10, 2025 · ⏱ 27 min read
Quick answer

Donations are the financial engine of most private MU Online servers, and Mercado Pago is the most widely used gateway in Brazil because it accepts Pix, card and boleto with a relatively direct integration. The challenge is not technical in the sense of code complexity, but of reliability: you need

Donations are the financial engine of most private MU Online servers, and Mercado Pago is the most widely used gateway in Brazil because it accepts Pix, card and boleto with a relatively direct integration. The challenge is not technical in the sense of code complexity, but of reliability: you need to guarantee that every approved payment turns into an in-game credit exactly once, that no credit is released without a real payment, and that network failures or fraud attempts do not open loopholes. In this tutorial you will build that bridge from scratch, creating the payment preference, handling the player's return and, most importantly, processing the webhook that confirms the money and triggers automatic credit delivery. API details and field names can change; Mercado Pago updates its documentation frequently, so treat the code as a working example to be checked against the current official reference.

The mental rule that runs through the entire integration is simple: the site never decides on its own that a payment was made. Mercado Pago decides, and the only reliable way to know is to query their API by payment ID. The player coming back to the site with a success message proves nothing, because that URL can be forged or accessed without paying. The whole architecture revolves around trusting only the authenticated response from the API.

Prerequisites

With the server already up (if it is not, see how to create a MU Online server), gather:

  • A Mercado Pago account with production credentials: the Public Key and, above all, the Access Token.
  • A site running PHP 7.4+ publicly reachable over HTTPS, since the webhook needs a public URL with TLS.
  • The cURL extension enabled in PHP to call the Mercado Pago API.
  • A working account and login system, so you know which account to credit.
  • A balance/credits table already defined in the database (WCoin, donation points or whatever your server uses).
  • A transaction control table with an idempotency key, like the one modeled in the Cash Shop tutorial.
  • A valid SSL certificate on the domain; Mercado Pago does not deliver webhooks to endpoints without trusted HTTPS.

Guard the Access Token with the same care as a database password. It lets anyone move money in your account through the API. Never put it in public versioned code, never expose it in the front end, and keep it in a configuration file outside the webroot.

How the donation flow works

Understanding the full cycle before coding avoids architectural mistakes. The flow has five well-defined stages, and each one has a clear owner.

StageWhere it happensOwnerTrustworthy to credit?
Player picks a packageSiteYour PHPNo
Preference creationSite calls MP APIYour PHP + APINo
PaymentMercado Pago screenMercado PagoNo
Player returnback_url on the siteBrowserNo
Webhook notificationMP API calls your endpointMercado PagoYes, after querying the API

Notice that only the last row is trustworthy for releasing credit. All the others are parts of the experience flow, but none proves payment. This table is the mental map that prevents the classic mistake of crediting in the back_url.

Storing the credentials securely

Isolate the credentials in a configuration file that lives outside the public folder and never goes into version control.

<?php
// config/mercadopago.php  (outside the webroot or protected)
return [
    'access_token' => getenv('MP_ACCESS_TOKEN') ?: 'APP_USR-xxxxxxxx',
    'public_key'   => getenv('MP_PUBLIC_KEY')   ?: 'APP_USR-yyyyyyyy',
    'base_url'     => 'https://api.mercadopago.com',
    'site_url'     => 'https://yourserver.com',
];

Preferring getenv() lets you inject the credentials via environment variables in production, which is the recommended practice. The hardcoded value is only a fallback for development. In production, set MP_ACCESS_TOKEN in the web server environment and leave the code with no secret written in it.

Creating the payment preference

The preference is the object that describes the donation: amount, description, return URLs and the notification URL. You create it by calling the Mercado Pago API, which returns a link the player is sent to.

<?php
// criar-doacao.php
session_start();
$cfg = require __DIR__ . '/config/mercadopago.php';

if (empty($_SESSION['conta_mu'])) {
    header('Location: /login');
    exit;
}

$pacotes = [
    'p1' => ['titulo' => 'Donation 1000 WCoin', 'valor' => 10.00, 'wcoin' => 1000],
    'p2' => ['titulo' => 'Donation 2500 WCoin', 'valor' => 20.00, 'wcoin' => 2500],
    'p3' => ['titulo' => 'Donation 6000 WCoin', 'valor' => 40.00, 'wcoin' => 6000],
];

$id = $_GET['pacote'] ?? '';
if (!isset($pacotes[$id])) { http_response_code(400); exit('Invalid package'); }
$p = $pacotes[$id];

// external_reference ties the payment to the account and package in our database
$refExterna = json_encode([
    'conta'  => $_SESSION['conta_mu'],
    'pacote' => $id,
    'wcoin'  => $p['wcoin'],
    'nonce'  => bin2hex(random_bytes(8)),
]);

$preferencia = [
    'items' => [[
        'title'       => $p['titulo'],
        'quantity'    => 1,
        'unit_price'  => $p['valor'],
        'currency_id' => 'BRL',
    ]],
    'external_reference' => $refExterna,
    'back_urls' => [
        'success' => $cfg['site_url'] . '/doacao-retorno.php?st=ok',
        'pending' => $cfg['site_url'] . '/doacao-retorno.php?st=pend',
        'failure' => $cfg['site_url'] . '/doacao-retorno.php?st=fail',
    ],
    'auto_return'  => 'approved',
    'notification_url' => $cfg['site_url'] . '/webhook-mercadopago.php',
];

$ch = curl_init($cfg['base_url'] . '/checkout/preferences');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $cfg['access_token'],
    ],
    CURLOPT_POSTFIELDS     => json_encode($preferencia),
]);
$resposta = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http !== 201) {
    error_log('Failed to create MP preference: ' . $resposta);
    exit('Error starting the payment. Please try again.');
}

$dados = json_decode($resposta, true);
// Redirect the player to the Mercado Pago checkout
header('Location: ' . $dados['init_point']);
exit;

The external_reference field is the glue between Mercado Pago and your database. In it you store the account, the package and the WCoin amount, plus a random nonce to make each preference unique. When the webhook arrives, you read that reference back and know exactly whom to credit and how much, without relying on a session or cookie, which do not exist in the webhook context.

The notification_url is the address Mercado Pago will call when the payment status changes. That endpoint is where the real work happens.

The player return page

When the payment finishes, Mercado Pago redirects the player to one of the back_urls. This page only serves to give visual feedback. It credits nothing.

<?php
// doacao-retorno.php
$status = $_GET['st'] ?? '';
$mensagens = [
    'ok'   => 'Payment received! Your credits will be released shortly.',
    'pend' => 'Payment pending. As soon as it is approved, the credits will land.',
    'fail' => 'The payment was not completed. No amount was charged.',
];
$msg = $mensagens[$status] ?? 'Unknown status.';
?>
<h1>Donation</h1>
<p><?= htmlspecialchars($msg) ?></p>
<p><a href="/painel">Back to the dashboard</a></p>

Notice the wording "will be released shortly" even on success. This is intentional: the release depends on the webhook, which normally arrives within seconds but is asynchronous. Promising immediate release on this page would be a lie, because the credit has not happened yet. Managing that expectation reduces "I paid and did not get it right away" complaints.

The webhook: where the credit actually happens

This is the most important endpoint in the whole integration. It receives the notification, queries the API to confirm the real status and, only then, credits idempotently.

<?php
// webhook-mercadopago.php
$cfg = require __DIR__ . '/config/mercadopago.php';
require_once __DIR__ . '/lib/cashshop.php';   // creditarWCoin() with idempotency

// 1) Responding quickly matters; we capture the payment ID
$entrada = json_decode(file_get_contents('php://input'), true);
$tipo = $_GET['type'] ?? $entrada['type'] ?? '';
$pagamentoId = $_GET['data.id'] ?? $entrada['data']['id'] ?? null;

if ($tipo !== 'payment' || !$pagamentoId) {
    http_response_code(200); // acknowledge and ignore what we do not care about
    exit;
}

// 2) NEVER trust the body. Query the API by ID.
$ch = curl_init($cfg['base_url'] . "/v1/payments/{$pagamentoId}");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $cfg['access_token']],
]);
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http !== 200) {
    http_response_code(500); // MP will retry later
    exit;
}

$pag = json_decode($resp, true);

// 3) Only approved payments get credited
if (($pag['status'] ?? '') !== 'approved') {
    http_response_code(200);
    exit;
}

// 4) Read the external_reference we tied on creation
$ref = json_decode($pag['external_reference'] ?? '{}', true);
$conta = $ref['conta'] ?? null;
$wcoin = (int)($ref['wcoin'] ?? 0);

if (!$conta || $wcoin <= 0) {
    http_response_code(200); // nothing to do
    exit;
}

// 5) Credit using the payment ID as the idempotency key
$ok = creditarWCoin($conta, $wcoin, 'mercadopago', (string)$pagamentoId);

http_response_code($ok ? 200 : 500);

Three decisions define the security of this webhook. First, it completely ignores any status value coming in the notification body and goes to fetch the truth from the API with the Access Token, which makes it impossible to forge an approved payment. Second, it uses the external_reference recorded at creation to know whom to credit, without relying on a session. Third, it passes the $pagamentoId as the idempotency key to the creditarWCoin function, which, together with the unique index in the database, guarantees that repeated webhooks (something normal in Mercado Pago) never credit twice.

The HTTP response code is also strategic. Returning 200 tells Mercado Pago "I received it, you can stop resending". Returning 500 says "something went wrong, try again later". That is why, when the API query fails due to a momentary instability, we deliberately respond 500: we want Mercado Pago to resend the notification until we manage to process it.

The idempotent credit function

The webhook depends on a credit function that is safe against duplication. It reuses exactly the same transactional logic as the Cash Shop system.

<?php
// lib/cashshop.php (essential excerpt)
function creditarWCoin(string $conta, int $qtd, string $origem, string $ref): bool {
    if ($qtd <= 0) return false;
    $conn = getDB();

    // The procedure does: validate account, check GatewayRef (idempotency),
    // add WCoin in a transaction and write the log. See the Cash Shop tutorial.
    $stmt = sqlsrv_query($conn, "{CALL dbo.CreditarWCoin(?, ?, ?, ?)}", [
        [$conta,  SQLSRV_PARAM_IN],
        [$qtd,    SQLSRV_PARAM_IN],
        [$origem, SQLSRV_PARAM_IN],
        [$ref,    SQLSRV_PARAM_IN],
    ]);

    if ($stmt === false) {
        error_log('Donation credit error: ' . print_r(sqlsrv_errors(), true));
        return false;
    }
    return true;
}

The $ref here is the Mercado Pago payment ID. Because the procedure writes that value into a column with a unique index, a second attempt to credit the same payment is blocked by the database itself. That is the difference between a system that withstands gateway resends and one that credits phantom credits.

Testing before going to production

Never put a payment system live without testing the full flow. Mercado Pago provides test credentials and test users that simulate payer and seller. With them you walk the entire path, from click to credit, without moving real money.

  1. Generate the test credentials in the Mercado Pago developer panel.
  2. Create test buyer and seller users.
  3. Use the test Access Token in the configuration.
  4. Make a donation with an approved test card and confirm the webhook credits.
  5. Make a donation with a declined card and confirm nothing is credited.
  6. Simulate a webhook resend and confirm the credit does not duplicate.
  7. Only then switch to the production credentials.

Testing the duplicate webhook case is the step most people skip and the one that causes the most losses later. Force it on purpose before you trust the system.

Security and compliance

Payments involve money and sensitive data, so some rules are non-negotiable. Never store card data in your database; leave that entirely with Mercado Pago by using Checkout Pro. Keep the Access Token out of public code and the front end. Serve the whole flow over HTTPS, including the webhook endpoint. Log every notification received for auditing, even the ignored ones, because that helps investigate disputes. And apply sanity limits: if a payment arrives with an amount that matches no known package, log it and do not credit automatically, treating it as a case for manual review.

It is worth stressing that this tutorial covers the technical integration, not financial, accounting or tax guidance. Rules on taxing donations and account movement are your responsibility and your accountant's; consult a professional for that part.

Common errors and fixes

SymptomLikely causeFix
Credit released without paymentCredit made on the back_urlMove the credit exclusively to the webhook
Webhook never arrivesnotification_url without valid HTTPSConfigure trusted SSL and a correct public URL
Player paid and did not receiveWebhook failed and returned 200Return 500 on failure to force a resend; check the logs
Double creditWebhook resend without idempotencyUse the payment ID as a unique key in the database
I do not know which account to creditexternal_reference not filled inStore the account and package in external_reference when creating the preference
Forged notification creditedTrusted the body without querying the APIAlways query /v1/payments/{id} with the Access Token
API error 401Wrong Access Token or a test one in productionConfirm the correct token for the current environment
Amount differs from the packageTampering or an altered packageValidate the paid amount against the package table

Launch checklist

  • Production Access Token stored outside the webroot and version control
  • The entire site served over HTTPS with a valid certificate
  • Payment preference created with external_reference containing account and wcoin
  • notification_url pointing to the correct public webhook
  • Webhook querying /v1/payments/{id} instead of trusting the body
  • Credit happening only when the status is approved
  • Payment ID used as the idempotency key in the credit
  • Unique index on the transaction reference column in the database
  • Return page purely informational, crediting nothing
  • HTTP codes 200/500 used correctly to control resends
  • Full flow tested with test credentials and test users
  • Duplicate webhook test confirming a single credit
  • Declined payment test confirming no credit
  • Logging of every notification received enabled
  • Per-transaction amount sanity limits configured

With this flow, your server receives donations via Pix and card automatically, securely and resistant to the resends that are part of the real life of any gateway. The principle that holds everything together repeats end to end: a credit is only born from a payment that the Mercado Pago API itself confirmed as approved, delivered exactly once.

Frequently asked questions

Do I need a company tax ID to use Mercado Pago on my server?

Not necessarily; the account can belong to an individual. However, for larger volumes and to reduce the risk of blocks due to unusual activity, many server owners register as a sole proprietor or open a business account. Account rules and limits vary and are defined by Mercado Pago, not by the code.

What is the difference between Checkout Pro and Transparent Checkout?

With Checkout Pro the player is redirected to the Mercado Pago screen, which handles everything, making it simpler and safer to start with. With Transparent Checkout the payment happens inside your own site, which demands more responsibility for handling data. For MU servers, Checkout Pro is usually the recommended path.

Should the credit be delivered on the return page or in the webhook?

Always in the webhook. The return page (back_url) only serves to show a message to the player and may be closed, reloaded or never even reached. Credit delivery has to happen in the webhook notification, which is the trustworthy source of the payment's real status.

How do I confirm the notification really came from Mercado Pago?

Never trust the notification body directly. When you receive the webhook, query the Mercado Pago API by payment ID using your Access Token and check the status returned by the API itself. Optionally, validate the header signature when available.

Does Pix credit the player instantly?

Pix confirms within seconds, but the in-game credit only lands when the webhook arrives and you query the API confirming the approved status. In practice it is a few seconds, but never credit before that confirmation, even if the player swears they paid.

BR
Events, maps & items editor

Bruno specializes in MU Online events, maps, bosses and item economy. He documents every detail based on real gameplay.

Keep reading

Related articles