Skip to main content

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

  1. 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.
  2. 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.
  3. 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.
  4. 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_centsdefault 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.
  5. 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.
  6. 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.
  7. 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).
  8. 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 25refundagainsta25 refund against a 100 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):
  1. 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.
  2. 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.