Key takeaways
- Your postback URL is not a secret. Verify the HMAC signature on every call and compare it in constant time.
- Store the last status per transaction ID inside a database transaction, so retries and duplicates can never pay twice.
- Keep the postback secret key on your server, answer with a 2xx within 6 seconds, and log every call.
What is postback security?
Postback security is the set of checks your server runs before it credits a user from a postback. At a minimum: verify the signature with your secret key, compare it in constant time, apply each transaction only once, and take rewards back only for transactions you credited. Anyone who finds your postback URL can call it, so a request arriving is never proof that a user earned anything. Get these checks right once, and every reward your users receive matches a result you were actually paid for.
This guide goes deeper than What is a postback URL?, which explains the concept. Here you get the threat model, a complete handler in PHP and Node.js built on the Sharklio signature, and the operational habits that keep it safe.
What you are defending against
| Threat | What it looks like | Defense |
|---|---|---|
| Forged calls | Someone calls your URL with status=1 and a big reward | Verify the HMAC signature |
| Tampered values | A real call with the user ID or reward changed | The signature covers those values |
| Duplicates and replays | The same event arrives twice after a retry, or is sent again on purpose | Idempotency per transaction ID |
| Race conditions | Two copies of one event arrive at the same moment | Row lock in a database transaction |
| Leaked secret | The key ends up in an app bundle, a repository, or a log | Server-side storage and key replacement |
| Signature guessing | Timing differences reveal how much of a guessed hash matched | Constant-time comparison |
Step 1: verify the HMAC signature
Every Sharklio postback can carry a {hash} macro. It is the HMAC-SHA256, as lowercase hex, of the transaction ID, user ID, reward, and status joined with colons, signed with your app’s postback secret key from the Integration tab:
hash = HMAC-SHA256( transaction_id + ":" + user_id + ":" + reward + ":" + status, postback_secret_key )
Include {transaction_id}, {user_id}, {reward}, {status}, and {hash} in your postback URL, with any parameter names you like:
https://yoursite.com/sharklio-postback?user={user_id}&tx={transaction_id}&event={event_id}&status={status}&reward={reward}&hash={hash}
Three rules prevent almost every mismatch:
- Hash the values exactly as they arrive. Do not cast the reward to a number and back, trim it, or reformat it before hashing. A reward of
0.296must be signed as0.296. - Keep the order: transaction ID, user ID, reward, status.
- Use the right key. The postback secret key is different from the link hash salt that signs your offerwall link.
Step 2: compare in constant time
A normal string comparison stops at the first different character, so it takes slightly longer the more characters match. That tiny difference can leak information about the correct value, so use a comparison built for secrets.
In PHP that is hash_equals($known, $userSupplied). The PHP manual describes it as a timing-attack-safe comparison and says the user-supplied string must be the second argument. In Node.js it is crypto.timingSafeEqual(a, b). The Node.js documentation says it throws an error if the two buffers have different byte lengths, so check the lengths first and treat a mismatch as invalid.
If the signature does not match, answer with 403 and do nothing else. Any answer outside 2xx counts as a failure, so a genuine call that failed because of a bug on your side is retried, which gives you time to fix it.
Step 3: apply each transaction once
On Sharklio, one completion keeps the same {transaction_id} from start to end, and each postback carries a status: 1 credited, 2 reversed, 3 pending, 4 rejected. Events of one transaction arrive in order, a reversal is never sent before the credit it cancels, and amounts are never negative. The status tells you whether to add or take back.
Store the last status you applied for each transaction ID and only allow these moves:
| Incoming status | Apply only if the last status is | Action |
|---|---|---|
| 1 credited | none, 3, or 4 | Add the reward |
| 2 reversed | 1 | Take back the reward you credited |
| 3 pending | none | Show as pending, credit nothing |
| 4 rejected | none, or 3 | Credit nothing |
Everything else is a repeat, so skip it and still answer 200. The {event_id} is unique for every event and useful in logs, but the status check per transaction is what keeps balances right.
Do the check and the balance change inside one database transaction with a row lock. Without the lock, two copies of the same event arriving at once can both read “not credited yet” and both pay.
A complete handler in PHP
This example uses PDO with MySQL or MariaDB and a table with transaction_id as its primary key. add_to_balance() and subtract_from_balance() stand for your own wallet functions, and they must use the same $pdo connection so they run inside the transaction.
$secret = getenv('SHARKLIO_POSTBACK_SECRET');
$tx = (string)($_GET['tx'] ?? '');
$userId = (string)($_GET['user'] ?? '');
$reward = (string)($_GET['reward'] ?? '');
$status = (string)($_GET['status'] ?? '');
$hash = (string)($_GET['hash'] ?? '');
$expected = hash_hmac('sha256', $tx . ':' . $userId . ':' . $reward . ':' . $status, $secret);
if (!hash_equals($expected, $hash)) {
http_response_code(403);
exit;
}
$s = (int)$status;
try {
$pdo->beginTransaction();
$pdo->prepare('INSERT IGNORE INTO sharklio_tx (transaction_id, user_id, reward) VALUES (?, ?, ?)')
->execute([$tx, $userId, $reward]);
$st = $pdo->prepare('SELECT user_id, reward, last_status FROM sharklio_tx WHERE transaction_id = ? FOR UPDATE');
$st->execute([$tx]);
$row = $st->fetch(PDO::FETCH_ASSOC);
$last = $row['last_status'] === null ? null : (int)$row['last_status'];
$next = null;
if ($s === 1 && $last !== 1 && $last !== 2) {
add_to_balance($pdo, $userId, $reward);
$next = 1;
} elseif ($s === 2 && $last === 1) {
subtract_from_balance($pdo, $row['user_id'], $row['reward']);
$next = 2;
} elseif ($s === 3 && $last === null) {
$next = 3;
} elseif ($s === 4 && ($last === null || $last === 3)) {
$next = 4;
}
if ($next !== null) {
$pdo->prepare('UPDATE sharklio_tx SET user_id = ?, reward = ?, last_status = ? WHERE transaction_id = ?')
->execute([$s === 2 ? $row['user_id'] : $userId, $s === 2 ? $row['reward'] : $reward, $next, $tx]);
}
$pdo->commit();
http_response_code(200);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('sharklio postback failed: ' . $e->getMessage());
http_response_code(500);
}
Two details are deliberate. The reversal takes back the reward stored with the credit, from the user it was credited to, rather than trusting new values. And an error returns 500, so the event is retried instead of lost.
The same handler in Node.js
This example uses Express and the same MySQL table. db.transaction() stands for your database client’s transaction helper, and addToBalance() and subtractFromBalance() for your wallet functions using the same transaction.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.SHARKLIO_POSTBACK_SECRET;
function validHash(q) {
const msg = `${q.tx}:${q.user}:${q.reward}:${q.status}`;
const expected = Buffer.from(crypto.createHmac('sha256', SECRET).update(msg).digest('hex'));
const given = Buffer.from(q.hash);
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
app.get('/sharklio-postback', async (req, res) => {
const q = req.query;
const fields = [q.tx, q.user, q.reward, q.status, q.hash];
if (fields.some((v) => typeof v !== 'string') || !validHash(q)) {
return res.sendStatus(403);
}
const s = Number(q.status);
try {
await db.transaction(async (t) => {
await t.query('INSERT IGNORE INTO sharklio_tx (transaction_id, user_id, reward) VALUES (?, ?, ?)', [q.tx, q.user, q.reward]);
const [row] = await t.query('SELECT user_id, reward, last_status FROM sharklio_tx WHERE transaction_id = ? FOR UPDATE', [q.tx]);
const last = row.last_status === null ? null : Number(row.last_status);
let next = null;
if (s === 1 && last !== 1 && last !== 2) {
await addToBalance(t, q.user, q.reward);
next = 1;
} else if (s === 2 && last === 1) {
await subtractFromBalance(t, row.user_id, row.reward);
next = 2;
} else if (s === 3 && last === null) {
next = 3;
} else if (s === 4 && (last === null || last === 3)) {
next = 4;
}
if (next !== null && s !== 2) {
await t.query('UPDATE sharklio_tx SET user_id = ?, reward = ?, last_status = ? WHERE transaction_id = ?', [q.user, q.reward, next, q.tx]);
} else if (next === 2) {
await t.query('UPDATE sharklio_tx SET last_status = 2 WHERE transaction_id = ?', [q.tx]);
}
});
res.sendStatus(200);
} catch (err) {
console.error('sharklio postback failed', err);
res.sendStatus(500);
}
});
The type check matters in Node.js: a repeated parameter such as ?tx=a&tx=b can turn a query value into an array, which would break the signature check in unexpected ways.
Step 4: answer fast, without redirects
- Answer with any 2xx within 6 seconds. Sharklio does not read the response body. Do slow work, such as emails or push notifications, after you answer or in a background job.
- Use the final URL. Sharklio does not follow redirects, so an http to https redirect, a missing or extra trailing slash, or a www redirect makes every postback fail. Enter the exact address your script answers on.
- Answer 200 for events you ignore, such as a repeat or a reversal for a transaction you never credited, so they are not retried.
- Use HTTPS, so values cannot be read or changed on the way.
Step 5: plan for retries
If your server fails or times out, Sharklio tries again up to the number of retries you choose in the Postback tab, after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. A pending event still waiting for a retry is dropped once the final status is known. This is why the idempotency step is not optional: a slow answer that your server actually processed can still be retried, and the second copy must change nothing.
Step 6: keep the secret key on the server
The key never goes to a client: not in JavaScript, not in a mobile app, not in a desktop build. Anything shipped to users can be extracted. Keep it out of your repository too. Load it from an environment variable or a secrets manager, and never write it to logs.
If it may have leaked, replace it. The Integration tab has a Replace your keys section. The old key stops working right away, so postback checks fail until your server uses the new one. Deploy the new key immediately, because retries give you only a short window.
Step 7: optional network checks
If you want an IP allow-list on top of the signature, ask [email protected] for the addresses we send from. Treat it as an extra layer, never a replacement for the HMAC check, and keep it up to date.
The opposite problem is more common: a firewall, a CDN’s bot protection, or a security plugin silently blocks server-to-server requests, and postbacks never arrive. Allow requests to your postback path, and check Logs, Postbacks in your dashboard, where every attempt is listed with the status code your server returned.
Step 8: log everything, then look at it
- Log every call: time, event ID, transaction ID, user ID, status, reward, whether the signature was valid, and what you did. When a user reports a missing reward, this answers it in seconds.
- Alert on invalid signatures. A burst of 403s means either someone is probing your URL or your key is out of date.
- Flag the unusual. Unknown user IDs, rewards far above your normal range, or one user receiving many credits in minutes deserve a look before cashouts. Answer 200 and review rather than blocking genuine events.
- Reconcile with the dashboard. Compare your records with Logs, Conversions and Logs, Chargebacks regularly.
Before going live, click Send test postback in the Postback tab. It sends status 1 for test_user with a transaction ID that starts with test_ and a $1 payout. Check that your server answered 200, that the reward arrived, and then remove it. The Testing and troubleshooting docs list the common errors and fixes.
Reversals are part of security too
A status 2 postback carries the same transaction ID and the same positive amounts as the credit it cancels. Take the reward back once, and only if you credited it. If the user already spent or cashed out the reward, your terms decide what happens, for example a negative balance or a hold on withdrawals. For currency with cash value, holding new earnings for a while before cashout limits the damage. See the Chargebacks docs and Publisher earnings and reversals.
A secure postback handler stops forged credits. It does not stop real fraud on your own property, such as one person running many accounts. That is covered in Stop multi-accounting and VPN abuse and the traffic quality rules.
Postback security checklist
- HMAC-SHA256 checked on every call with the postback secret key.
- Values hashed exactly as received, in the order transaction ID, user ID, reward, status.
- Constant-time comparison:
hash_equalsortimingSafeEqualwith a length check. - Last status stored per transaction ID, with a row lock in a database transaction.
- Credit on 1, take back on 2 only after a credit, nothing on 3 or 4.
- 2xx within 6 seconds, no redirects, 200 for ignored events, 500 on your own errors.
- Secret key only on the server, out of the repository and the logs.
- Firewall and bot protection allow the postback path.
- Every call logged, invalid signatures alerted, records reconciled with the dashboard.
What a Sharklio app gives your postback handler
Every Sharklio app gets its own postback secret key, signed postbacks for pending, credited, rejected, and reversed results under one transaction ID, retries you can configure, a test button, and a postback log that shows each attempt and your server’s answer. Sharklio has not launched yet, and publisher applications open soon. You can already write the handler above against the documented signature, so your wall is ready to credit users safely from its first day. See how the Sharklio offerwall works or read the Postbacks reference.
Frequently asked questions
How do I verify a postback is real?
Recalculate the signature on your server with your secret key and the values in the request, then compare it with the hash you received using a constant-time function. On Sharklio, sign the transaction ID, user ID, reward, and status joined with colons using HMAC-SHA256.
Is an IP allow-list enough to secure a postback URL?
No. Use it only as an extra layer. The signature check proves the values were not changed, which an IP check cannot do.
Why does my postback hash not match?
Usually because a value was changed before hashing, the order is wrong, or the link hash salt was used instead of the postback secret key. Hash the values exactly as received.
How do I stop a postback from crediting twice?
Store the last status you applied for each transaction ID and skip repeats. Do the check and the credit inside one database transaction with a row lock, so two simultaneous copies cannot both pay.
What should my postback URL return?
Any 2xx status within 6 seconds for every event you accepted or deliberately ignored, and a non-2xx status only when you want the call retried, such as when your database is down.