turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

PAY-001

Verify a CoinPayments IPN before you credit it

The HMAC is over the raw POST body, not the parsed array. Rebuilding it from $_POST is why so many handlers reject every callback.

CoinPayments signs each IPN with an HMAC-SHA512 of the raw request body, using your IPN secret, and sends it in the HTTP_HMAC header. Verifying it is four lines.

The mistake that costs an afternoon is rebuilding the body from $_POST with http_build_query. That re-encodes the values, so the bytes differ from what was signed and every callback fails verification with no clue why. Read the raw input stream instead.

Status is the other half. CoinPayments reports progress with increasing numbers; 100 or 2 means complete, anything negative is cancelled or timed out, and everything between is still pending and must not credit.

PHP
function coinpayments_ipn(PDO $pdo, string $ipnSecret, string $merchantId): void
{
    // The signature is over the RAW body. Rebuilding it from $_POST re-encodes
    // the values and the HMAC will never match.
    $raw = file_get_contents('php://input');
    $sent = (string) ($_SERVER['HTTP_HMAC'] ?? '');

    if ($raw === false || $raw === '' || $sent === '') {
        http_response_code(400);
        exit('no payload');
    }
    $expected = hash_hmac('sha512', $raw, $ipnSecret);
    if (!hash_equals($expected, $sent)) {
        http_response_code(403);
        exit('bad hmac');
    }
    if (($_POST['merchant'] ?? '') !== $merchantId) {
        http_response_code(403);
        exit('wrong merchant');
    }

    $status = (int) ($_POST['status'] ?? -999);
    $custom = (string) ($_POST['custom'] ?? '');      // your own reference
    $amount = (float) ($_POST['amount1'] ?? 0);       // in the currency you priced in

    if ($status < 0) {
        mark_deposit_failed($pdo, $custom, 'cancelled or timed out');
        exit('IPN OK');
    }
    if ($status < 100 && $status !== 2) {
        exit('IPN OK');                                // still pending: acknowledge, credit nothing
    }

    credit_deposit($pdo, $custom, (int) round($amount * 100000000), (string) ($_POST['txn_id'] ?? ''));
    exit('IPN OK');
}

Using it

Always answer 200 with a short body, even for a pending status. A non-200 makes CoinPayments retry the same notification for days.

Credit against your own custom reference with a unique index, so the repeats that do arrive are no-ops rather than double credits.

What bites people

Never trust amount1 without the HMAC covering it. The signature is what makes the figure meaningful.

Status 2 means a queued payout on the sending side and 100 means complete; both are terminal successes for a deposit. Treating only 100 as success leaves real payments pending.

Log the raw body on every call. When one deposit sticks, that row is the whole investigation.

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 Payment Rails