Your postback URL is a public endpoint that credits users. Without a signature check it is a public endpoint that credits users for anybody who guesses the format, and the format is in the URL you gave us.
The check is about twenty lines. Here it is in three languages, followed by the two ways people get it wrong.
The scheme
Every postback carries a signature parameter. It is an HMAC-SHA256 over three fields joined by colons, keyed with your placement's secret key:
userId : transactionId : currencyAmount
The secret key is on your placement's integration screen. It is never sent in a postback and never appears in a URL — if it did, it would not be a secret.
PHP
$userId = $_GET['user_id'] ?? '';
$transactionId = $_GET['transaction_id'] ?? '';
$amount = $_GET['amount'] ?? '';
$signature = $_GET['signature'] ?? '';
$expected = hash_hmac(
'sha256',
$userId . ':' . $transactionId . ':' . $amount,
OFFERWALL_SECRET_KEY
);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit('0');
}
Node.js
const crypto = require('crypto');
const { user_id: userId, transaction_id: txId, amount, signature } = req.query;
const expected = crypto
.createHmac('sha256', process.env.OFFERWALL_SECRET_KEY)
.update(`${userId}:${txId}:${amount}`)
.digest('hex');
const ok = signature
&& signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) {
return res.status(403).send('0');
}
Python
import hmac, hashlib
user_id = request.args.get('user_id', '')
tx_id = request.args.get('transaction_id', '')
amount = request.args.get('amount', '')
sent = request.args.get('signature', '')
expected = hmac.new(
OFFERWALL_SECRET_KEY.encode(),
f'{user_id}:{tx_id}:{amount}'.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sent):
return '0', 403
Mistake one: comparing with ==
A normal string comparison stops at the first differing byte, so it takes measurably longer to reject a signature that is right for the first ten characters than one that is wrong immediately. That difference is enough to recover a valid signature one byte at a time, over enough requests.
Use the constant-time comparison your language provides: hash_equals, crypto.timingSafeEqual, hmac.compare_digest. Every example above does.
Note the length check in the Node version: timingSafeEqual throws if the two buffers differ in length, so a short signature would crash the handler rather than fail the check. Compare lengths first, then the bytes.
Mistake two: verifying, then ignoring the result
More common than it sounds. The check runs, the mismatch is logged, and the code carries on and credits the user anyway — because the crediting was written first and the check was added later, in front of it, without a return.
Test it. Send yourself a postback with one character changed in the signature and confirm the user is not credited. If you have not done that, you do not know that your check works; you know that it runs.
What the signature does not cover
Three fields, deliberately: the user, the transaction and the amount. That is what must not be tampered with. The rest — the offer name, the payout in dollars, the timestamp — is informational.
So do not credit an amount you took from a field outside the signature, and do not decide what a conversion is worth from your own copy of the offer. Credit the signed amount.
And then be idempotent
A verified postback can still arrive twice — we retry until we get a success, and networks retry us. Store the transaction id with a unique index and treat a repeat as already done. There is a separate article on exactly that, because it is the bug that most often survives launch.