Most of my Django projects are made from one template of my own, docker-core. Its optional crypto module (USE_CRYPTO_SERVICE) carries a small NOWPayments client. This is how it creates a payment, trusts a callback, reads a payment's state and finds payments whose callback never came. Most of it I learned from bugs in production.
What NOWPayments does, and when it fits
NOWPayments is a hosted crypto payment gateway. You create an invoice priced in a fiat currency, the customer pays on the gateway's checkout page in a coin they choose, and the gateway calls your server back as the payment moves. That callback is the IPN. The template can also take a payment from a connected wallet and read the chain itself; NOWPayments fits when the customer should choose among many coins and you don't want to read every chain yourself. The client only receives; payouts are deliberately left out.
Creating a payment
A payment starts as POST /v1/invoice with the x-api-key header. Every method returns (ok, data, error); ok == False means the request failed, never that a payment was refused.
payload = {
"price_amount": float(amount),
"price_currency": price_currency or NOWPAYMENTS_PRICE_CURRENCY, # "usd"
"is_fixed_rate": NOWPAYMENTS_IS_FIXED_RATE,
"is_fee_paid_by_user": NOWPAYMENTS_IS_FEE_PAID_BY_USER,
}
# NOWPayments refuses an explicit null, so a value never set is left out.
optional = {
"order_id": str(order_id) if order_id is not None else None,
"order_description": order_description,
"ipn_callback_url": ipn_callback_url or NOWPAYMENTS_IPN_CALLBACK_URL,
"success_url": success_url or NOWPAYMENTS_SUCCESS_URL,
"cancel_url": cancel_url or NOWPAYMENTS_CANCEL_URL,
"pay_currency": pay_currency,
"partially_paid_url": partially_paid_url or NOWPAYMENTS_PARTIALLY_PAID_URL,
}
payload.update({key: value for key, value in optional.items() if value})
ok, data, error = self._request("POST", "/v1/invoice", payload=payload)
if not ok:
return False, None, error
data["invoice_url"] = normalize_checkout_url(data["invoice_url"])
return True, data, None
Two details came from production. NOWPayments refuses an explicit null instead of ignoring it, so unset fields are left out. And the gateway hands back a checkout URL with a trailing slash, then redirects it through a plain http:// hop; checkout is a form POST, so the browser checks that hop against the CSP's form-action and blocks it, leaving the customer on a dead page. normalize_checkout_url drops the slash. The caller keeps the invoice's id to match the payment to its order.
The IPN, and why its signature is checked
The IPN URL is public. Anyone who finds it can POST a body saying your order is finished. What tells NOWPayments apart is the x-nowpayments-sig header: an HMAC-SHA512, keyed with the dashboard's IPN secret, over the body with its keys sorted and no whitespace. With no secret configured nothing is accepted, and the comparison is constant-time. The view passes the raw request.body and the header, and answers 403 on False.
@staticmethod
def verify_ipn_signature(raw_body, signature) -> bool:
if not signature or not NOWPAYMENTS_IPN_SECRET_KEY:
return False
messages = _signed_messages(raw_body)
if not messages: # not JSON
return False
key = str(NOWPAYMENTS_IPN_SECRET_KEY).encode()
return any(
hmac.compare_digest(
hmac.new(key, message.encode(), hashlib.sha512).hexdigest(),
str(signature),
)
for message in messages
)
The bitcoin IPN that was refused as forged
The first version signed what json.dumps wrote with sorted keys, which is NOWPayments' own Python example. It worked until bitcoin. NOWPayments signs what JavaScript's JSON.stringify writes, and Python writes any number below 0.0001 differently: 5.123e-05 where JavaScript writes 0.00005123. A bitcoin payment's amounts and fees are exactly such numbers. Every bitcoin IPN was answered 403 as forged, and an order whose customer had closed the tab was never settled.
Now the check tries each string NOWPayments may have signed, likeliest first: the numbers as the body wrote them, as JavaScript writes them, and json.dumps's form. That loosens nothing: each candidate is still an HMAC under the same secret over the same data.
def _signed_messages(raw_body) -> list:
as_sent = json.loads(raw_body, parse_float=_JsonNumber, parse_int=_JsonNumber)
parsed = json.loads(raw_body)
messages = (
_sorted_json(as_sent, str), # each number as the body wrote it
_sorted_json(parsed, _js_number), # as JavaScript writes it
json.dumps(parsed, sort_keys=True, separators=(",", ":")), # the Python example
)
return list(dict.fromkeys(messages))
What each state means
check_payment turns NOWPayments' nine statuses into a verdict:
| payment_status | Verdict | The caller |
|---|---|---|
finished | STATE_PAID | settles the order |
partially_paid | STATE_PARTIALLY_PAID | decides what the part that arrived is worth |
waiting, confirming, confirmed, sending | STATE_PENDING | waits |
failed, refunded, expired | STATE_FAILED | closes it |
| unknown status | STATE_PENDING | waits; an API change destroys nothing |
| gateway unreachable | STATE_SERVICE_ERROR | keeps the order, asks again later |
A 404 counts as failed: the gateway answered, and it does not know the payment.
partially_paid is not pending. It was counted as pending at first; that was the second bug. The coins arrived, fewer than the invoice asked for, and whatever the customer sends on top is booked as a separate payment carrying parent_payment_id. This one never becomes finished, so a caller waiting on it waited for ever with the money already in. What to credit is the project's decision; the share that arrived is actually_paid / pay_amount.
Payments whose IPN never arrived
An IPN can be lost: a firewall, a server unreachable from abroad, a restart longer than the gateway's retries. If the customer never returns either, only the account's list of payments, GET /v1/payment/, knows about it. It wants a JWT from POST /v1/auth, and that wants the dashboard's own email and password, the same login that creates payouts. So it is optional, and fenced:
- Without
NOWPAYMENTS_EMAILandNOWPAYMENTS_PASSWORD, nothing is asked. - The login goes over https or not at all.
- The token goes back to the one call that asked and is kept nowhere: not on the client, not in the cache (Redis has no password), not in a log. It lives five minutes.
- No error names the login or the token.
def get_account_payments(self, date_from=None, date_to=None, invoice_id=None):
ok, token, error = self.authenticate()
if not ok:
return False, [], error
payments, page, renewed = [], 0, False
while page < ACCOUNT_PAYMENTS_MAX_PAGES:
ok, data, error, http_status = self.list_payments(
token, date_from, date_to, invoice_id, page
)
if not ok and http_status in (401, 403) and not renewed:
renewed = True
ok, token, error = self.authenticate()
if ok:
continue
if not ok:
return False, [], error # all the pages, or none
rows, pages = _page_rows(data)
payments.extend(rows)
page += 1
if not rows or page >= pages:
return True, payments, None
return False, [], f"Error: more than {ACCOUNT_PAYMENTS_MAX_PAGES} pages of payments."
get_account_payments returns every page of the window or nothing; a caller handed one page as the whole list would take the rest for never paid. An expired token is renewed once, not in a loop.
The server holds the other half: .env at chmod 600, owned by the deploy user, and docker access treated as root access, since docker inspect shows every container's environment. In the dashboard: two-factor on, payouts confirmed by it, payout addresses whitelisted, so a leaked password still cannot move money.
Keys live in .env, never in code
In the template a setting lives in the database by default. A key goes to .env when it authenticates something and cannot be stored encrypted, and every NOWPayments key is that kind:
NOWPAYMENTS_SANDBOX=False
NOWPAYMENTS_API_KEY=<your-api-key>
NOWPAYMENTS_IPN_SECRET_KEY=<your-ipn-secret>
# optional: only for the account's list of payments
NOWPAYMENTS_EMAIL='<dashboard-email>'
NOWPAYMENTS_PASSWORD='<dashboard-password>'
# optional: where api.nowpayments.io cannot be reached directly
NOWPAYMENTS_USE_PROXIES=False
NOWPAYMENTS_PROXY_URL=socks5://host.docker.internal:1085
The single quotes matter: compose expands a $ otherwise, and the password reaches the container changed. After changing the password, run docker compose up -d; a restart keeps the old environment.
How I test it
The tests never reach NOWPayments; they patch requests.request with a small fake gateway that records what it was sent.
- The IPN fixture is a bitcoin body and the string NOWPayments' documented
JSON.stringify(sortObject(body))makes of it under Node. Forged or missing signatures are refused. - Every documented status maps to its verdict, and an unknown one to pending.
- For the account: no login, no request; an http base URL, no request; a refused login leaves the email and password out of errors and stdout; a 401 midway gives auth, page, auth, page.
def test_python_writes_small_amounts_another_way():
"""The cause: the check signed `json.dumps`'s string."""
as_python = json.dumps(json.loads(JS_BODY), sort_keys=True, separators=(",", ":"))
assert as_python != JS_SIGNED
assert "5.123e-05" in as_python
def test_an_ipn_signed_over_small_bitcoin_amounts_is_accepted():
assert _verify(JS_BODY.encode(), _sign(JS_SIGNED))
docker compose run --rm -e USE_CRYPTO_SERVICE=True backend uv run pytest _0_utils/_modules/crypto --nomigrations
On a server, nowpayments_doctor walks the same path from inside the container and stops at the first failing step: config (only the API key's first four characters), the proxy socket, GET /v1/status. It can also create a real invoice, or sign in and count the last day's payments, printing no secret.
uv run manage.py nowpayments_doctor
uv run manage.py nowpayments_doctor --invoice 100
uv run manage.py nowpayments_doctor --account
What I kept from it
Check a signature against the string the sender signed, not the one your language writes. Treat an unknown state as pending and a partial payment as final. And treat the dashboard login as a payout credential, because it is one.