turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

FP-009

Idempotency keys on a FaucetPay v2 payout

Generate the key and save it on the claim row before you call send, then reuse it on every retry. Generating it at call time is the same as having none.

v2 requires an idempotency key on send, which turns the hardest problem in payouts — a timeout that may or may not have paid — into a safe retry. Only if you use it correctly.

The key has to exist before the request leaves and has to survive the failure. Generate it, write it to the claim or withdrawal row, then call. On retry, read the same key back and send it again: FaucetPay recognises the repeat and returns the original payout rather than making a second one.

A key generated inside the send call is a new key on every attempt, which is exactly the situation the mechanism exists to prevent.

PHP
function pay_withdrawal(PDO $pdo, int $withdrawalId): array
{
    $st = $pdo->prepare('SELECT * FROM withdrawals WHERE id = ? AND status = "held" FOR UPDATE');
    $pdo->beginTransaction();
    $st->execute([$withdrawalId]);
    $w = $st->fetch(PDO::FETCH_ASSOC);
    if (!$w) {
        $pdo->rollBack();
        return ['ok' => false, 'reason' => 'not held'];
    }

    // The key is created ONCE and persisted BEFORE the call. A retry reuses it.
    $key = (string) $w['idempotency_key'];
    if ($key === '') {
        $key = 'wd-' . $withdrawalId . '-' . bin2hex(random_bytes(12));
        $pdo->prepare('UPDATE withdrawals SET idempotency_key = ? WHERE id = ?')->execute([$key, $withdrawalId]);
    }
    $pdo->commit();                                  // commit before the HTTP call

    $r = fp_call(setting('fp_send_key'), 'send', [
        'idempotency_key' => $key,
        'to'              => $w['address'],
        'amount'          => (int) $w['units'],      // integer smallest units
        'currency'        => $w['currency'],
        'ip_address'      => $w['request_ip'],
    ]);

    if ($r['ok']) {
        $pdo->prepare('UPDATE withdrawals SET status = "paid", payout_id = ?, paid_at = NOW() WHERE id = ? AND status = "held"')
            ->execute([(string) ($r['data']['payout_id'] ?? ''), $withdrawalId]);
        return ['ok' => true];
    }

    if ($r['http'] === 0 || $r['http'] === 429) {
        // Unknown, or a cap/rate limit. Stays held; the SAME key retries later.
        $pdo->prepare('UPDATE withdrawals SET attempts = attempts + 1, note = ? WHERE id = ?')
            ->execute([substr($r['message'], 0, 190), $withdrawalId]);
        return ['ok' => false, 'retry' => true];
    }

    // A definite refusal: safe to reject and refund.
    reject_withdrawal($pdo, $withdrawalId, $r['message']);
    return ['ok' => false, 'retry' => false];
}

Using it

Commit the key before the HTTP call. Holding a transaction open across a 25 second timeout is how shared hosting runs out of database connections.

429 means a daily cap or a rate limit, not a failure. Back off and retry with the same key — it is one of the cases the key was designed for.

Keep an attempts counter and stop automatic retries after a few, so a permanently stuck row reaches a human instead of looping.

What bites people

Never regenerate the key on retry. That is the one mistake that makes double payment possible again.

Do not refund on http 0. The payout may have gone out; hold it and reconcile by payout id.

The key is yours to choose, so make it unique per withdrawal and never reuse one across rows.

This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.

Also in FaucetPay