FP-007
A payout queue that survives an API that says nothing
Auto-pay when the API answers, hold when it does not, and never refund on a timeout. The three-state queue that keeps a faucet solvent.
Payout APIs give three answers: yes, no, and silence. Yes and no are easy. Silence — a timeout, a dropped connection, a gateway error — is where money gets lost, because the request may well have succeeded on the other end.
The rule that follows from that: a refusal is final and can be refunded, a silence is unknown and must be held. Code that treats both as failure will refund payouts that were actually sent, and users will notice before you do.
Holding costs a little support effort. Refunding a completed payout costs the payout.
function process_payout(PDO $pdo, array $withdrawal): string
{
try {
$res = faucetpay_send(
setting('fp_api_key'),
$withdrawal['address'],
$withdrawal['currency'],
(int) $withdrawal['units'],
'wd-' . $withdrawal['id'] // our reference, for reconciliation
);
} catch (RuntimeException $e) {
// Silence. It may have gone through. Hold it for a human.
$pdo->prepare('UPDATE withdrawals SET status = "held", note = ?, attempts = attempts + 1 WHERE id = ?')
->execute([substr($e->getMessage(), 0, 190), $withdrawal['id']]);
return 'held';
}
if ((int) ($res['status'] ?? 0) === 200) {
$pdo->prepare('UPDATE withdrawals SET status = "paid", txid = ?, paid_at = NOW() WHERE id = ? AND status <> "paid"')
->execute([(string) ($res['payout_id'] ?? ''), $withdrawal['id']]);
return 'paid';
}
// A definite refusal: safe to return the funds.
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE withdrawals SET status = "rejected", note = ? WHERE id = ? AND status = "held"')
->execute([substr((string) ($res['message'] ?? 'refused'), 0, 190), $withdrawal['id']]);
$pdo->prepare('UPDATE users SET balance = balance + ? WHERE id = ?')
->execute([(int) $withdrawal['units'], (int) $withdrawal['user_id']]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
return 'rejected';
}
Using it
Cap attempts. A withdrawal that has been held three times needs a person, not a fourth automatic try.
Show held withdrawals in admin with the error text and a Paid / Refund pair of buttons, so clearing them is two clicks and not a database query.
What bites people
Never refund inside the catch block. That is the one line that turns an outage into a loss.
Pass your own reference on every send. When you have to ask whether a held payout actually went out, that reference is how you find it.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.