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.
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.