AdPlusMedia Docs
Docs S2S Postback

Postback Security

Your postback endpoint is a public URL that credits real balances. You must verify that every call genuinely came from us before crediting anyone.

The signature

Include the {sig} macro in your postback URL. We replace it with an MD5 hash built from your app ID, the user ID and your secret key, concatenated in that exact order with no separator.

text
sig = md5(app_id + user_id + api_secret)
ComponentSource
app_idThe numeric ID of your placement.
user_idThe value we send you as {user_id} on this call.
api_secretYour placement's secret key. Never leaves your server.

Recompute the hash yourself and compare it to the value we sent. If they do not match, respond with a non-200 status and credit nothing. Never trust the query string on its own.

Verification examples

php
// callbacks/adplusmedia.php
            $appId  = '1042';
            $secret = 'YOUR_SECRET_KEY'; // from your placement settings

            $userId = $_GET['user_id'] ?? '';
            $reward = $_GET['reward']  ?? '';
            $status = $_GET['status']  ?? '';
            $debug  = $_GET['debug']   ?? '0';
            $sig    = $_GET['sig']     ?? '';

            $expected = md5($appId . $userId . $secret);

            if (! hash_equals($expected, $sig)) {
                http_response_code(403);
                exit('ERROR: Signature does not match');
            }

            if ($debug === '1') {
                http_response_code(200);
                exit('ok');
            }

            // Credit or reverse, then always return 200.
            $status === '2'
                ? subtractBalance($userId, $reward)
                : addBalance($userId, $reward);

            http_response_code(200);
            echo 'ok';
python
import hashlib
            import hmac
            from flask import Flask, request

            app = Flask(__name__)

            APP_ID = "1042"
            SECRET = "YOUR_SECRET_KEY"

            @app.route("/callbacks/adplusmedia")
            def postback():
                user_id = request.args.get("user_id", "")
                reward  = request.args.get("reward", "")
                status  = request.args.get("status", "1")
                debug   = request.args.get("debug", "0")
                sig     = request.args.get("sig", "")

                expected = hashlib.md5(
                    (APP_ID + user_id + SECRET).encode()
                ).hexdigest()

                if not hmac.compare_digest(expected, sig):
                    return "ERROR: Signature does not match", 403

                if debug == "1":
                    return "ok", 200

                if status == "2":
                    subtract_balance(user_id, reward)
                else:
                    add_balance(user_id, reward)

                return "ok", 200
javascript
const express = require('express');
            const crypto = require('crypto');

            const app = express();

            const APP_ID = '1042';
            const SECRET = 'YOUR_SECRET_KEY';

            app.get('/callbacks/adplusmedia', (req, res) => {
                const { user_id: userId, reward, status, debug, sig } = req.query;

                const expected = crypto
                    .createHash('md5')
                    .update(APP_ID + userId + SECRET)
                    .digest('hex');

                if (expected !== sig) {
                    return res.status(403).send('ERROR: Signature does not match');
                }

                if (debug === '1') {
                    return res.status(200).send('ok');
                }

                status === '2'
                    ? subtractBalance(userId, reward)
                    : addBalance(userId, reward);

                res.status(200).send('ok');
            });

            app.listen(3000);
ruby
require 'sinatra'
            require 'digest'

            APP_ID = '1042'
            SECRET = 'YOUR_SECRET_KEY'

            get '/callbacks/adplusmedia' do
              user_id = params['user_id'].to_s
              reward  = params['reward'].to_s
              status  = params['status'].to_s
              debug   = params['debug'].to_s
              sig     = params['sig'].to_s

              expected = Digest::MD5.hexdigest(APP_ID + user_id + SECRET)

              halt 403, 'ERROR: Signature does not match' unless expected == sig
              halt 200, 'ok' if debug == '1'

              status == '2' ? subtract_balance(user_id, reward) : add_balance(user_id, reward)

              'ok'
            end
java
import org.springframework.web.bind.annotation.GetMapping;
            import org.springframework.web.bind.annotation.RequestParam;
            import org.springframework.web.bind.annotation.RestController;

            import java.math.BigInteger;
            import java.security.MessageDigest;

            @RestController
            public class PostbackController {

                private static final String APP_ID = "1042";
                private static final String SECRET = "YOUR_SECRET_KEY";

                @GetMapping("/callbacks/adplusmedia")
                public ResponseEntity<String> postback(
                        @RequestParam("user_id") String userId,
                        @RequestParam String reward,
                        @RequestParam String status,
                        @RequestParam String debug,
                        @RequestParam String sig) throws Exception {

                    MessageDigest md = MessageDigest.getInstance("MD5");
                    byte[] digest = md.digest((APP_ID + userId + SECRET).getBytes("UTF-8"));
                    String expected = String.format("%032x", new BigInteger(1, digest));

                    if (!expected.equals(sig)) {
                        return ResponseEntity.status(403).body("ERROR: Signature does not match");
                    }

                    if (!"1".equals(debug)) {
                        if ("2".equals(status)) {
                            subtractBalance(userId, reward);
                        } else {
                            addBalance(userId, reward);
                        }
                    }

                    return ResponseEntity.ok("ok");
                }
            }
csharp
using Microsoft.AspNetCore.Mvc;
            using System.Security.Cryptography;
            using System.Text;

            [ApiController]
            [Route("callbacks/adplusmedia")]
            public class PostbackController : ControllerBase
            {
                private const string AppId = "1042";
                private const string Secret = "YOUR_SECRET_KEY";

                [HttpGet]
                public IActionResult Postback(
                    [FromQuery(Name = "user_id")] string userId,
                    string reward, string status, string debug, string sig)
                {
                    var bytes = Encoding.UTF8.GetBytes(AppId + userId + Secret);
                    var hash = Convert.ToHexString(MD5.HashData(bytes)).ToLowerInvariant();

                    if (hash != sig)
                    {
                        return StatusCode(403, "ERROR: Signature does not match");
                    }

                    if (debug != "1")
                    {
                        if (status == "2") SubtractBalance(userId, reward);
                        else AddBalance(userId, reward);
                    }

                    return Ok("ok");
                }
            }

Prevent duplicate credits

A failed postback is retried up to five times. If your server credited the user but then timed out or returned a non-200 status, you will receive the same conversion again.

Store {transaction_id} against each conversion you process and ignore any call whose transaction you have already seen. It is unique per conversion, so it is the correct idempotency key. Without this, a single retry double-credits the user.

php
// Reject replays before touching the balance.
    if (conversionExists($_GET['transaction_id'] ?? '')) {
        http_response_code(200);
        exit('ok'); // Already processed — 200 stops the retry.
    }

Do not deduplicate on {event_id}. On advertiser campaigns it is an event name shared by every user who completes that goal, so treating it as unique would discard real conversions.

Endpoint checklist

  • Serve the endpoint over HTTPS with a valid certificate.
  • Verify {sig} on every request, using a constant-time comparison.
  • Respond with HTTP 200 and a short body. Anything else is treated as a failure.
  • Respond in under 10 seconds. Queue slow work instead of doing it inline.
  • Never redirect. We do not follow redirects, and a 301 is recorded as a failure.
  • Do not require authentication, cookies or a session on this route.
  • Handle status=2 chargebacks as well as credits.
  • Skip crediting when debug=1.

If your postbacks are failing, check your placement's postback log in the dashboard first. It records the exact URL we called and the status we received back.