Your endpoint will be called twice with the same conversion. Not might — will. Everything below follows from that.
Why it happens
A postback is a request that must not be lost, so every system in the chain retries until it is acknowledged. The dangerous case is not the one where the call failed. It is the one where it succeeded and the acknowledgement did not arrive:
- Your server credited the user, then timed out before responding. We saw a timeout, so we retried.
- Your response was a 500 from something after the credit — a logging call, a push notification, an analytics write.
- A proxy or load balancer retried the request itself.
- A network re-sent an old conversion during a reconciliation run, days later.
In every one of those, the work was done and the sender has no way to know. So it comes again, and your endpoint has to be the thing that notices.
The pattern
Store the transaction id, with a unique index, in the same transaction as the credit.
CREATE TABLE offerwall_credits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
transaction_id VARCHAR(191) NOT NULL,
user_id VARCHAR(191) NOT NULL,
amount DECIMAL(18,5) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_transaction (transaction_id)
);
Then let the database enforce it:
$db->beginTransaction();
try {
$db->prepare('INSERT INTO offerwall_credits (transaction_id, user_id, amount)
VALUES (?, ?, ?)')
->execute([$transactionId, $userId, $amount]);
} catch (PDOException $e) {
$db->rollBack();
if ($e->errorInfo[1] === 1062) { // duplicate key
http_response_code(200);
exit('1'); // already done — a success, not an error
}
throw $e;
}
$db->prepare('UPDATE users SET balance = balance + ? WHERE id = ?')
->execute([$amount, $userId]);
$db->commit();
exit('1');
Why the unique index and not a SELECT first
Because two copies of the same postback can arrive at the same moment, and this is exactly the shape of code that looks correct and is not:
// BROKEN under concurrency
if (!$db->query('SELECT 1 FROM offerwall_credits WHERE transaction_id = ...')->fetch()) {
credit_the_user();
record_the_transaction();
}
Both requests run the SELECT before either runs the INSERT, both find nothing, and both credit. It will pass every test you write, because your tests are sequential, and it will fire the first time a network retries aggressively.
The unique index is not an optimisation of the check. It is the check. Only one INSERT can win, and the loser finds out by being refused.
Answer a duplicate as a success
A repeat is a final answer, not a failure. If you answer with an error, the sender assumes it still has not landed and keeps retrying — a queue that never drains for a conversion you have already paid.
The same logic applies to a conversion you deliberately refuse: if you have decided not to credit it, say so once, definitively, and record that decision. "Try again" is only correct when trying again might work.
Answer quickly
Credit, respond, and do everything else afterwards. Analytics, push notifications, leaderboard updates and webhooks to your own services belong after the response or on a queue.
An endpoint that takes five seconds under load is an endpoint that starts timing out on a busy evening, and every timeout is a retry, and every retry makes it slower. That spiral is the actual cause of most double-credit incidents — not the duplicate itself, but a slow endpoint manufacturing them.
Reversals are idempotent too
A reversal can be delivered twice for the same reason. Make the debit conditional on the credit still standing — act only on a conversion that is currently credited — so the second delivery finds nothing to do and says so.
That one is easy to miss, because it only bites on money going the other way, and by then it is a balance that has been debited twice for one clawback.