Crypto refunds — operator runbook
This runbook is for platform operators (super-admins) processing
NOWPayments crypto refunds. The tenant-facing view of the crypto top-up
flow lives in Cryptocurrency payments.
No dashboard button yet — this is API-only today. The inline “Refund
crypto” button described in earlier drafts of this runbook has not
shipped in the admin dashboard. Do not go looking for it in the
transactions table — it is not there. Today, crypto refunds are initiated
by calling the admin API directly (POST /api/v1/admin/billing/crypto-refund)
with a super-admin session. The rest of this runbook documents that API
call. A dashboard affordance is planned; until it lands, the API is the
supported path and this page is the operator source of truth.
The backend endpoint exists and is the path this runbook uses: it
validates the inputs, calls NOWPayments POST /v1/payout, records a
pending refund row, and audit-logs the action. Only the UI trigger is
missing. There is no tenant-side crypto refund path — crypto refunds are
operator-only to prevent self-service mistakes that would be irrecoverable.
When to refund
Process a refund when ALL of the following hold:
- The tenant has opened a support ticket with a clear refund reason
(wrong-amount transfer, dispute closed in tenant’s favour, accidental
top-up, etc.).
- The tenant has provided a destination wallet address. Confirm with
the tenant via the same channel as the ticket — never accept an
address from a different channel without re-confirmation.
- The destination address is on the same chain as the original
payment. Cross-chain refunds will lose the funds; refuse them.
- The tenant is in the same jurisdiction window the operator is
comfortable refunding (consult legal / finance for edge cases).
- The original top-up is recent enough that the refund makes sense.
Operationally we cap at 90 days from the original transaction —
older requests need escalation. This 90-day cap is an operator
policy you apply by hand, not a server-side limit: the crypto
refund endpoint does not check the transaction age and will not block a
refund on an older top-up, so it is on you to check the date before
submitting. (This differs from the tenant-facing fiat credit refund,
which rejects lots older than 30 days in the API. Because crypto
refunds are operator-only, the age judgement — including any approved
escalation past 90 days — stays with you.)
When to refuse
Refuse (or escalate) when ANY of these hold:
- The original transaction is older than 90 days (an operator policy —
the endpoint will not enforce this for you; verify the date yourself and
escalate rather than submit if it is past 90 days).
- There is an active fraud review or chargeback dispute on the org.
- The destination wallet address is suspected of fraud or sanctions
exposure (consult the AML SOP).
- The amount requested exceeds the original top-up.
- The tenant insists on a different chain than the original payment.
- The tenant’s spending pattern shows the refunded amount has already
been spent on services rendered.
When refusing, reply to the ticket with the reason and a path forward
(e.g. “send a fresh top-up first, then we can refund the original”).
Step-by-step
-
Verify the ticket. Open the tenant’s support ticket. Confirm the
original transaction id (
txn_* / the credit_transactions.id of the
finished top-up), the requested refund amount, and the destination
wallet address.
-
Confirm the wallet address out-of-band. Reply on the ticket asking
the tenant to confirm the destination address. Wait for the tenant
to reply with the same address before proceeding — this is the
primary defence against ticket compromise.
-
Find the original top-up’s transaction id. There is no admin
transactions table with an inline refund button yet, so pull the id
from the ledger. The original top-up is the
credit_transactions row
whose payment_method is crypto_nowpayments:
The id you pass as transaction_id MUST be the ORIGINAL finished
top-up — not a prior refund row. Refunding against a top-up whose
payment_method is anything other than crypto_nowpayments is
rejected with a 422 invalid_payment_method before NOWPayments is
ever called.
-
Submit the refund via the admin API. Call the endpoint with a
super-admin session (the admin OTP cookie or a super-admin API key —
the same auth the admin dashboard uses). The request body is:
Field rules the endpoint enforces:
destination_address — chain-agnostic shape check (26–95 chars,
letters and digits only, trimmed). It is not validated against
the specific chain; NOWPayments’ payout endpoint rejects a
mismatched address with a 4xx that the API surfaces verbatim. Paste
exactly what the tenant confirmed.
amount_cents — default to the full original top-up. The
endpoint accepts a partial amount (1 cent up to the original) and
pays out the crypto proportionally, and it lets you issue several
partials against one top-up as long as their running total never
exceeds the original — it tracks the cumulative refunded-so-far
amount and rejects an over-refund (or a refund on an
already-fully-refunded top-up) with a 409. But do NOT submit a
one-off partial. A lone partial over-reclaims the tenant’s wallet,
because the wallet-settlement leg always reclaims the FULL original
credit regardless of the amount submitted here — only a set of
partials that sums back to the full original nets out correctly. If
the tenant needs just part of a top-up returned, use the
full-refund-then-fresh-top-up path or a manual USD credit instead.
Read “Partial refunds” under Edge cases for the proportional formula,
the cumulative state machine, and the wallet-settlement caveat
before submitting anything other than the full amount.
reason — optional but always set it to the ticket reference; it is
recorded on the audit-log entry.
-
Double-check before you send. There is no confirmation dialog to
catch a typo, so re-read the request body before submitting:
destination_address matches — character for character — what the
tenant confirmed, and amount_cents is the full original top-up in
cents. This is an irreversible money movement once the network
confirms; the request itself is your only checkpoint.
-
Submit. On success the endpoint returns
200 with:
Behind that response the system:
- Records a pending refund row in
credit_transactions (type
crypto_refund_pending, negative amount_cents, reference = crypto_refund:<transaction_id>, payment_method = crypto_nowpayments) before calling the provider.
- Calls NOWPayments
POST /v1/payout synchronously and stamps the
payout_id back onto the pending row.
- Writes an audit-log entry (
admin.crypto_refund_initiated) with
the operator user id, ticket reference, address, and amount.
-
Wait for the IPN. NOWPayments fires a
refunded IPN when the
on-chain payout confirms. The IPN handler:
- Reclaims the tenant’s wallet credit for the original payment,
idempotently keyed on the original payment so a duplicate IPN never
double-deducts. The amount reclaimed is the full USD value credited
at top-up time (FX-locked) — it exactly undoes the original top-up.
This wallet-settlement leg is distinct from the on-chain payout leg:
the payout the operator initiates is proportional to the refund
amount (see “Partial refunds”), whereas the
refunded IPN carries
no per-refund amount and always reclaims the full original credit.
Read the “Partial refunds” caveat before issuing a single partial
refund.
- Updates the
crypto_topup_intents row to refunded.
- Emits a second audit-log entry (
billing.crypto_topup_ipn_refunded,
resource crypto_topup_intents).
-
Reply to the ticket. Once the IPN lands and the wallet has been
deducted, reply to the ticket with the payout id + on-chain
transaction hash (when NOWPayments includes it on the IPN payload).
Reconciliation
To find all crypto refunds processed by the operator team:
To match a pending refund to its eventual refunded IPN:
The correlation key is the original top-up’s NOWPayments payment_id,
not the payout_id minted by createPayout. The IPN handler
settles the deduct with the idempotency key
nowpayments:refund:<payment_id>, so joining on payout_id (as an
earlier version of this runbook did) returns NULL for every real
refund — making every settled refund look stuck. The deduct row is
written by deductBalance, which records type = 'debit' (it does not
re-label crypto refunds), so the settled row is matched on
type = 'debit' plus the deterministic idempotency key.
The pending row is created by the API call; the settled row is created
by the IPN handler. If the settled row is NULL for a row older than 24
hours, the on-chain payout has not yet been confirmed (or has failed) —
escalate to platform engineering.
Edge cases
Refund larger than the org’s current wallet balance. The IPN
handler’s deductBalance call is allowed to take the wallet below zero
in this case — the org goes into negative balance pending operator
reconciliation. Surface this as a follow-up task in the same ticket
(“your balance is now -$X — please top up or contact support before
sending again”).
Partial refunds. Supported on the on-chain payout leg. The admin
endpoint accepts any refund amount from 1 cent up to the original
top-up and computes the crypto payout proportionally:
where actually_paid_crypto is the crypto amount the customer originally
sent (recorded on the original NOWPayments intent by its finished IPN).
So a 25refundagainsta100 top-up that was paid with 0.004 BTC pays
out 0.001 BTC. The payout is always denominated in the original pay
currency, never in USD dollars.
The endpoint tracks the cumulative amount already refunded against each
top-up and rejects, with a 409, any request that would push the running
total past the original, or any refund against a top-up that has already
been fully refunded. You can therefore issue several partial refunds
against one top-up, as long as they sum to no more than the original.
Wallet-settlement caveat — read before issuing a single partial. The
on-chain payout is proportional, but the wallet-balance leg is not. The
refunded IPN carries no per-refund amount, so the IPN handler reclaims
the full original credited wallet amount the first time it settles
(idempotent on the original payment, so a duplicate IPN never
double-deducts). A set of partials that sums to
the original nets out correctly — one full wallet reclaim against the
full set of crypto payouts. But a single partial leaves the wallet
reclaim larger than the crypto actually returned. When a tenant wants
only part of a top-up back and you do not intend to refund the rest,
prefer one of the following instead of a lone on-chain partial:
- Refund the original in full, then ask the tenant to send a fresh
top-up for the amount they want to keep on the wallet, or
- Credit the agreed amount back manually in USD via the bonus credits
tool and refuse the on-chain refund.
Multi-currency refunds. Out of scope at launch. NOWPayments
payouts are always in the SAME crypto currency as the original
top-up. If the tenant asks for a refund in a different coin, refuse
and offer either (a) refund in the original coin or (b) cancel the
refund and credit the wallet manually in USD.
The original row is not tagged crypto_nowpayments. The endpoint
only accepts a transaction_id whose payment_method is
crypto_nowpayments; anything else returns 422 invalid_payment_method.
Migration 228 added the payment_method column and backfilled 'stripe'
only for rows whose metadata marked them as Stripe (a
stripe_session_id or stripe_payment_intent_id, or a source of
stripe/auto_topup); it left every other legacy row NULL rather than
tagging it 'stripe'. So a genuine crypto top-up created before the
column existed reads NULL — not crypto_nowpayments — unless it was
explicitly tagged at write time, and the endpoint will reject it. If a
crypto row genuinely needs a refund and its payment_method is NULL,
escalate to platform engineering — there is a manual SQL path documented
in the internal runbook.
The refund is rejected with a 422 before NOWPayments is called.
The admin endpoint runs two VALIDATION_ERROR guards against the
original payment intent before it ever hits the provider. These are
not NOWPayments errors — do NOT escalate them as a provider failure:
- 422
pay_currency_missing — the original NOWPayments intent has no
pay_currency on file, so the payout coin cannot be derived.
- 422
actually_paid_crypto_missing — the original payment’s crypto
amount (actually_paid) is not on file, so the proportional payout
cannot be computed.
Both fire when the original payment’s finished IPN has not been
processed yet (or the intent JOIN missed — e.g. a pre-229 row with no
nowpayments_payment_id). This is the common case when an operator
tries to refund too soon. The remedy is to wait for the reconciler to
sync the intent’s actually_paid / pay_currency, then retry the
refund. Do not retry in a tight loop and do not raise a provider ticket.
NOWPayments returns a 4xx on the payout call. The endpoint surfaces
the provider’s error message verbatim (as a 422 VALIDATION_ERROR). The
two common ones:
- 422 “Invalid address” — the destination address is not valid for the
pay currency. Re-ask the tenant; this typically means a wrong-chain
paste.
- 422 “Amount below minimum” — NOWPayments has per-currency minimum
payouts. For tiny refunds the only path is to credit the wallet in
USD via the bonus credits tool and refuse the on-chain refund.
NOWPayments returns 5xx on the payout call. The endpoint returns a
502 with a generic “Try again in a few minutes” message. The pending
ledger row is already persisted; re-submitting the same
(transaction_id, destination_address, amount_cents) collapses onto the
same id via the deterministic key, so it is safe to retry.
Audit trail
Every refund initiates two audit-log entries (chain-linked via the
tamper-evident audit chain):
admin.crypto_refund_initiated — written by the admin controller
the moment the refund API call is accepted. Contains the operator
user id, source IP, ticket reason, destination address, amount,
original transaction id, payout id from NOWPayments, and initial
payout status.
billing.crypto_topup_ipn_refunded — written by the IPN handler
when the refunded IPN lands (resource crypto_topup_intents). The
action name is templated as billing.crypto_topup_ipn_${status}, so
the refunded IPN yields billing.crypto_topup_ipn_refunded. It does
NOT carry the on-chain transaction hash or the new wallet balance; it
records the NOWPayments payment_id, the payment_status, the
actually_paid amount, and the outcome_amount / outcome_currency.
Only the initiation entry is keyed on a resource id: query the
audit-logs view with resource_id = '<refund_ledger_id>' to find the
admin.crypto_refund_initiated row (resource credit_transaction,
resourceId = refundLedgerId). The IPN settlement entry
(billing.crypto_topup_ipn_refunded) has resource crypto_topup_intents
and carries no resource_id, so it cannot be found that way. Correlate
the two entries by organization_id plus the NOWPayments payment_id
(the settlement row records payment_id in its details), not by a shared
resource_id.