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.
sig = md5(app_id + user_id + api_secret)
| Component | Source |
|---|---|
app_id | The numeric ID of your placement. |
user_id | The value we send you as {user_id} on this call. |
api_secret | Your 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
// 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';
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
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);
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
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");
}
}
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.
// 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
200and 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
301is recorded as a failure. - Do not require authentication, cookies or a session on this route.
- Handle
status=2chargebacks 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.