turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

PAY-002

Create a NOWPayments invoice and handle the callback

An API key on the way out, a signed and key-sorted JSON payload on the way back. The sorting requirement is the part that trips people.

NOWPayments takes a JSON invoice request with your API key in a header and returns a hosted payment URL you redirect the user to. That half is unremarkable.

The callback is where the specifics matter: the signature is an HMAC-SHA512 over the JSON payload with its keys sorted alphabetically, not over the raw body as received. Sign the raw bytes and it will never match. Re-encode with sorted keys and it will.

As always, the order is verify, then check state, then credit against your own reference inside a transaction.

PHP
function nowpayments_create_invoice(string $apiKey, float $usd, string $orderId, string $callbackUrl, string $successUrl): ?string
{
    $payload = json_encode([
        'price_amount'     => $usd,
        'price_currency'   => 'usd',
        'order_id'         => $orderId,          // your reference, comes back verbatim
        'ipn_callback_url' => $callbackUrl,
        'success_url'      => $successUrl,
    ]);

    $ch = curl_init('https://api.nowpayments.io/v1/invoice');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_HTTPHEADER     => ['x-api-key: ' . $apiKey, 'Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    $body = curl_exec($ch);
    curl_close($ch);

    $data = json_decode((string) $body, true);
    return $data['invoice_url'] ?? null;
}

function nowpayments_callback_valid(string $ipnSecret): bool
{
    $raw = (string) file_get_contents('php://input');
    $sent = (string) ($_SERVER['HTTP_X_NOWPAYMENTS_SIG'] ?? '');
    $data = json_decode($raw, true);

    if (!is_array($data) || $sent === '') {
        return false;
    }

    // Signed over the payload with keys sorted alphabetically — NOT the raw body.
    ksort($data);
    $sorted = json_encode($data, JSON_UNESCAPED_SLASHES);

    return hash_equals(hash_hmac('sha512', (string) $sorted, $ipnSecret), $sent);
}

Using it

Credit only on payment_status of finished or confirmed. waiting, confirming and sending are all still in flight.

Store payment_id alongside your order id. Support questions on their side are asked in terms of theirs.

What bites people

ksort is not recursive. A nested object in the payload needs sorting at every level, or the signature will not match on those calls.

partially_paid is a real outcome: the user underpaid. Decide the policy — credit what arrived, or hold it — before it happens rather than during.

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