FP-011
Verify a FaucetPay v2 payout webhook
payout.sent and payout.failed arrive signed. Check the HMAC, dedupe on the event id, and let the webhook correct a payout your send call never heard about.
Webhooks are what close the loop on a payout whose API call timed out. The send may have succeeded with no response reaching you; the webhook arrives later and says so.
Verify the signature before reading anything. The body is signed with your webhook secret, so the check is an HMAC over the raw body, compared in constant time.
Dedupe on the event id, not on the payout id. A payout produces more than one event over its life, and the event id is the thing that repeats when they retry.
function fp_webhook_event(string $secret): ?array
{
$raw = (string) file_get_contents('php://input');
$sig = (string) ($_SERVER['HTTP_X_FAUCETPAY_SIGNATURE'] ?? '');
if ($raw === '' || $sig === '' || $secret === '') {
return null;
}
if (!hash_equals(hash_hmac('sha256', $raw, $secret), $sig)) {
return null;
}
$event = json_decode($raw, true);
return is_array($event) ? $event : null;
}
// webhook.php
$event = fp_webhook_event(setting('fp_webhook_secret'));
if ($event === null) {
http_response_code(401);
exit;
}
try {
// UNIQUE on event_id: a retry does nothing.
q('INSERT INTO fp_events (event_id, type, payload, created_at) VALUES (?, ?, ?, NOW())',
[(string) $event['id'], (string) $event['type'], json_encode($event)]);
} catch (PDOException $e) {
http_response_code(200);
exit; // already handled
}
$payoutId = (string) ($event['data']['payout_id'] ?? '');
switch ($event['type']) {
case 'payout.sent':
// Settles a withdrawal left held by a timeout.
q('UPDATE withdrawals SET status = "paid", paid_at = NOW() WHERE payout_id = ? AND status <> "paid"', [$payoutId]);
break;
case 'payout.failed':
// Now it is definite, so the refund is safe.
refund_held_withdrawal_by_payout($payoutId, (string) ($event['data']['reason'] ?? ''));
break;
}
http_response_code(200);
Using it
Answer 200 quickly and do the work after, or do it inside a short transaction. A slow webhook endpoint gets retried while it is still working.
Match withdrawals by payout id, which means storing the payout id as soon as a send returns one.
A payout.failed is the only safe trigger for refunding a held withdrawal automatically. Everything else should wait for a person.
What bites people
Verify against the raw body. Re-encoding the decoded array changes the bytes and the signature will never match.
Do not dedupe on payout id — you will drop the failure event for a payout you already recorded as sent.
An unknown event type should be stored and ignored, not rejected. They add types.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.