SHL-002
Verify a shortlink completion with a signed return token
Providers do not postback, so the round trip is the only evidence. Single use, account bound, expiring, minimum dwell, and starting a new run kills the old one.
Almost no shortlink provider sends a server-to-server notification. What you get is the user coming back to a return URL you chose, which means the proof has to be something you issued and can recognise.
A signed token in the return URL does that. It is bound to the user and the link, it expires, it is single use, and the minimum dwell check compares when it was issued with when it came back.
The hole people miss is tabs. Without expiring any open run when a new one starts, a user opens ten tokens in ten tabs, passes through the provider once and completes all ten. That one rule is the difference between a gate and a formality.
function shortlink_start(PDO $pdo, int $userId, array $link): ?string
{
if (shortlink_remaining($pdo, $userId, (int) $link['id']) < 1) {
return null; // daily cap reached
}
// Anything still open for this user is abandoned: no ten-tabs trick.
$pdo->prepare('UPDATE shortlink_runs SET status = "expired" WHERE user_id = ? AND status = "open"')
->execute([$userId]);
$token = bin2hex(random_bytes(16));
$pdo->prepare(
'INSERT INTO shortlink_runs (user_id, link_id, token, status, ip, started_at)
VALUES (?, ?, ?, "open", ?, NOW())'
)->execute([$userId, (int) $link['id'], $token, client_ip()]);
$returnUrl = url('r.php?t=' . $token);
return shorten($link, $returnUrl); // provider API call
}
function shortlink_return(PDO $pdo, string $token, int $userId, int $minSeconds = 20): array
{
$pdo->beginTransaction();
try {
$st = $pdo->prepare(
'SELECT *, TIMESTAMPDIFF(SECOND, started_at, NOW()) AS away
FROM shortlink_runs WHERE token = ? FOR UPDATE'
);
$st->execute([$token]);
$run = $st->fetch(PDO::FETCH_ASSOC);
if (!$run || $run['status'] !== 'open') {
$pdo->rollBack();
return ['ok' => false, 'why' => 'That link was already used.'];
}
if ((int) $run['user_id'] !== $userId) {
$pdo->rollBack();
return ['ok' => false, 'why' => 'That link belongs to another account.'];
}
if ((int) $run['away'] < $minSeconds) {
$pdo->rollBack();
return ['ok' => false, 'why' => 'That was too quick — finish the page and come back.'];
}
if ((int) $run['away'] > 1800) {
$pdo->prepare('UPDATE shortlink_runs SET status = "expired" WHERE id = ?')->execute([$run['id']]);
$pdo->commit();
return ['ok' => false, 'why' => 'That link expired. Start it again.'];
}
// Single use: the status change IS the guard, inside the lock.
$pdo->prepare('UPDATE shortlink_runs SET status = "done", completed_at = NOW() WHERE id = ?')
->execute([$run['id']]);
credit_user($userId, (int) $run['reward_units'], 'shortlink', $token);
$pdo->commit();
return ['ok' => true];
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
Using it
Keep the return URL short and boring. Some providers refuse long ones, and a few strip query strings they do not like.
Offer both a {return} encoded and a raw form in the provider row — a handful expect the destination unencoded.
Record the address at start and at return. They differ for mobile users more often than you would guess, so do not fail on a mismatch; just record it.
What bites people
Expire open runs when a new one starts. Without it the whole scheme is decorative.
Lock the run row while completing it. Two returns in the same second otherwise credit twice.
Set the minimum dwell from the provider's own timer plus a few seconds, not from a guess. Too high and honest users on slow phones fail.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.