Payment webhook integration for your MU Online server's shop
Set up payment webhooks (Mercado Pago, PagSeguro, Stripe) in your MU Online server's online shop, with signature validation, idempotency, and automatic VIP/Zen/Cash Points crediting.
The Cash Points shop is one of the main revenue sources for a private MU Online server, and its weakest link is usually payment confirmation itself. Relying solely on the "payment approved" screen in the player's browser is a recipe for chargebacks, fraud, and duplicate credits. The correct solution
The Cash Points shop is one of the main revenue sources for a private MU Online server, and its weakest link is usually payment confirmation itself. Relying solely on the "payment approved" screen in the player's browser is a recipe for chargebacks, fraud, and duplicate credits. The correct solution is to integrate webhooks: asynchronous notifications the payment gateway sends directly to your server, confirming that the money actually came in. This tutorial covers setting up webhooks with Mercado Pago, PagSeguro, and Stripe, signature validation, idempotency handling, and automatically crediting VIP, Zen, or Cash Points to the player's account, along with the most common mistakes first-timers make.
Why browser confirmation isn't enough
When the player finishes paying, the gateway redirects their browser to a "success" URL on your site. That redirect isn't reliable as a source of truth: the player might close the tab before it completes, lose their connection, or in rare cases try to manipulate the return URL to forge a ?status=approved. The webhook, by contrast, is a server-to-server HTTP call, cryptographically signed, fired by the gateway itself when the payment status actually changes on their end.
Overview of the full flow
- The player picks a Cash Points package in the web shop and is redirected to the gateway's checkout.
- The gateway processes the payment (Pix, card, boleto).
- The gateway sends a webhook POST to a URL of yours, reporting the transaction status.
- Your backend validates the signature, checks idempotency, and credits the amount to the player's credit table (read by the MU server at login or via an in-game command).
- The gateway also redirects the player's browser to the success page — purely for UX, not as a source of truth.
Comparing the main gateways used by Brazilian MU servers
| Gateway | Signature header | Payload format | Note |
|---|---|---|---|
| Mercado Pago | x-signature (HMAC SHA256) | JSON with the payment's data.id | Needs to fetch the full payment via API after the webhook |
| PagSeguro/PagBank | Token in the notification URL | XML/JSON depending on the API (legacy vs. new) | Legacy API sends only the code; new API sends the full payload |
| Stripe | Stripe-Signature (HMAC SHA256) | Full event JSON | Official library already validates the signature (stripe.webhooks.constructEvent) |
| Pagar.me | x-hub-signature (HMAC SHA1) | Full JSON | Format similar to Mercado Pago |
Step 1 — Create the endpoint that receives the webhook
Use a dedicated route, outside of any user session authentication (the gateway has no session cookie):
<?php
// webhook_pagamento.php
$payload = file_get_contents('php://input');
$headers = getallheaders();
// never trust the payload before validating the signature
if (!validarAssinatura($payload, $headers['x-signature'] ?? '')) {
http_response_code(401);
exit('invalid signature');
}
$evento = json_decode($payload, true);
processarEvento($evento);
http_response_code(200);
Step 2 — Validate the signature (HMAC)
Never process a webhook without confirming it really came from the gateway. The principle is the same across all of them: recompute the HMAC with your secret key and compare it against the received header, using a constant-time comparison to avoid timing attacks:
function validarAssinatura(string $payload, string $assinaturaRecebida): bool {
$chaveSecreta = getenv('WEBHOOK_SECRET');
$assinaturaCalculada = hash_hmac('sha256', $payload, $chaveSecreta);
return hash_equals($assinaturaCalculada, $assinaturaRecebida);
}
Store WEBHOOK_SECRET in an environment variable, never in versioned source code.
Step 3 — Fetch the real transaction status (when needed)
Some gateways (Mercado Pago is the classic case) send only an ID in the webhook, and you need to query their API to confirm the actual status — this prevents a forged webhook claiming an "approved" status from being accepted without cross-checking:
$paymentId = $evento['data']['id'];
$resposta = httpGet("https://api.mercadopago.com/v1/payments/{$paymentId}", [
'Authorization: Bearer ' . getenv('MP_ACCESS_TOKEN')
]);
$pagamento = json_decode($resposta, true);
if ($pagamento['status'] !== 'approved') {
http_response_code(200); // acknowledge the event, but don't credit
exit;
}
Step 4 — Ensure idempotency
Gateways resend the same webhook multiple times if they don't get a 200 in time, or due to retry policy. Record the transaction ID before crediting, and reject duplicates:
CREATE TABLE webhook_eventos (
id_transacao VARCHAR(100) PRIMARY KEY,
processado_em DATETIME,
valor_creditado DECIMAL(10,2)
);
if (transacaoJaProcessada($paymentId)) {
http_response_code(200);
exit; // already credited, avoid duplicating
}
Step 5 — Credit VIP, Zen, or Cash Points
Once validated and confirmed not to be a duplicate, credit the table the GameServer reads (directly in the MU database, or an intermediary table that a service credits periodically):
UPDATE AccountCash SET CashPoints = CashPoints + @valorComprado
WHERE AccountID = @conta;
INSERT INTO webhook_eventos (id_transacao, processado_em, valor_creditado)
VALUES (@paymentId, GETDATE(), @valorComprado);
If your emulator uses a Web Cash Shop with its own table (e.g., IGCN_CashShop), credit the corresponding structure and make sure the GameServer rereads the balance at the next login or via a real-time event, if supported.
Step 6 — Notify the player
Send a confirmation email and, if possible, an in-game notification (mailbox, system message) confirming the credit. This drastically reduces "I paid and didn't receive it" support tickets, since many cases are just a few seconds of delay between payment and webhook.
Testing webhooks locally
Gateways can't reach localhost. Use a public tunnel during development:
ngrok http 8443
# copy the generated https URL and register it as the webhook endpoint in the gateway's dashboard
All the gateways mentioned offer a sandbox/test mode — use test cards and accounts before pointing to production.
Extra security: IP whitelisting and rate limiting
In addition to signature validation, restrict the webhook endpoint by source IP when the gateway publishes a fixed list (Stripe and PagSeguro publish ranges), and apply rate limiting on the endpoint to mitigate brute-force attempts against signature validation.
Logs and auditing
Log every payload received (even those rejected for invalid signature) in a separate log, with timestamp and source IP. This is essential for investigating chargeback disputes and auditing whether any credit was applied incorrectly.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Cash Points credited twice | Webhook resent without an idempotency check | Record the transaction ID and ignore duplicates |
| Webhook never arrives | Endpoint without valid HTTPS or wrong URL in the dashboard | Set up a valid certificate and review the URL registered with the gateway |
| Signature always invalid | Wrong secret key or payload altered before validation (e.g., framework parsing it) | Validate against the raw body, before any decoding |
| Player paid but didn't receive anything | Credit failure mistakenly returned 200 to the gateway | Return 500 on credit failure to force a resend |
| Test (sandbox) webhook processed in production | Sandbox and production sharing the same endpoint without distinction | Use separate endpoints/keys for sandbox and production |
| Chargeback isn't reflected in the game | No handling for refund/chargeback events | Implement a handler for refund events and remove credit/apply a ban per policy |
Payment integration checklist
- Dedicated HTTPS endpoint for webhooks, outside normal session authentication.
- HMAC signature validation implemented and tested with a real payload.
- Real status lookup via the gateway's API when the webhook only carries an ID.
- Idempotency table recording already-processed transaction IDs.
- VIP/Zen/Cash Points crediting automated and validated in sandbox.
- Player notification (email and/or in-game) after crediting.
- Logs of all payloads received, including rejected ones.
- Handling of refund/chargeback events implemented.
With reliable webhooks and the shop crediting automatically, it's worth reviewing the overall site and server architecture to make sure the rest of your infrastructure can handle the transaction volume safely — start with the MU Online server setup guide if you haven't reviewed the base yet.
Frequently asked questions
Why can't I just trust the payment success redirect?
Because the redirect happens in the player's browser, which can be manipulated, interrupted, or never completed (closed tab, dropped connection). The webhook comes directly from the payment gateway's server to your backend, asynchronously and reliably, and is the only source of truth for releasing credit.
How do I know a webhook really came from the payment gateway and not a scammer?
Every serious gateway signs the payload with a secret key (HMAC) or sends a signature header. You recompute that signature on your backend with your secret key and compare it — if it doesn't match, discard the request. Never process a webhook without this validation.
What is idempotency and why is it mandatory here?
It's making sure processing the same event twice doesn't credit Cash Points in duplicate. Gateways resend webhooks when they don't get a confirmation in time, so you need to record the event/transaction ID and ignore duplicates before crediting.
Do I need HTTPS to receive webhooks?
Yes, it's required by practically every gateway (Mercado Pago, Stripe, PagSeguro) — they reject or warn about HTTP endpoints. Use a valid certificate (Let's Encrypt is enough) on your shop's domain.
What do I do if crediting fails after the payment was already approved?
Log the failure in a retry queue and return HTTP 500 to the gateway so it automatically resends the webhook. Never return 200 if the credit wasn't applied — that makes the gateway stop retrying, and the player never gets what they paid for.