Skip to main content
NOWPayments is a crypto-only hosted invoice gateway. Use it when you want to accept Bitcoin, Ethereum, USDC/USDT and 300+ other coins without going through a Coinbase-style KYC. The merchant flow is email-only signup; the buyer picks their coin on a hosted page and you receive crypto in your wallet. Pick NOWPayments if you’re an indie hacker who wants to add a crypto checkout option alongside (or in place of) a card processor — and don’t need auto-debit subscriptions.
No subscriptions. NOWPayments has a “recurring billing” feature, but it’s just an email-resend of new invoices each cycle — not auto-debit, because on-chain crypto wallets can’t pre-authorize recurring withdrawals. The provider in vibestrap throws on type: 'subscription' to fail loudly rather than silently book bad UX. Use NOWPayments for one-time / lifetime / credit-pack purchases. Pair with Stripe (USDC) or Helio if you need true crypto subscriptions.No customer portal. createPortalLink throws — there’s no NOWPayments equivalent of Stripe’s billing portal.

Prerequisites

  • A NOWPayments account at nowpayments.io. No KYC required for basic merchant signup — just an email.
  • A receiving wallet address registered in the NOWPayments dashboard (Settings → Payment settings → Outcome wallet). This is where your crypto lands. Use a non-custodial wallet you control (Ledger / Trezor / a trusted CEX deposit address).
  • An IPN secret generated in Settings → Store settings → IPN Secret.
  • Postgres up, schema pushed.

1. Set the active provider

In src/config/site.ts:
payment: {
  provider: 'nowpayments' as 'stripe' | 'paddle' | 'lemonsqueezy' | 'creem' | 'nowpayments',
  currency: 'usd',
},
currency stays 'usd' — you price in USD on your end, and NOWPayments quotes the equivalent crypto amount at checkout time.

2. Set env vars

In .env.local (variable names match src/env.ts):
NOWPAYMENTS_API_KEY=YOUR_API_KEY              # Settings → Store settings → API key
NOWPAYMENTS_IPN_SECRET=YOUR_IPN_SECRET        # Settings → Store settings → IPN Secret

# vibestrap product prices in USD (string form — "49" means $49)
NOWPAYMENTS_PRICE_VIBESTRAP_PROMO=49
NOWPAYMENTS_PRICE_VIBESTRAP_STANDARD=99
NOWPayments has no concept of a “product” or “price ID”. Other providers’ PRICE_* env vars hold a Stripe price_xxx / Creem product id / etc.; NOWPayments’ just hold the dollar amount as a string. The provider parses it to a number and POSTs it as price_amount directly.

3. Configure the IPN webhook

NOWPayments calls these “IPN” (Instant Payment Notifications). In Settings → IPN settings:
  • IPN callback URL: https://your-domain.com/api/webhooks/nowpayments
vibestrap also sends ipn_callback_url per-invoice so it works even if the dashboard global is unset — but setting both is harmless and lets failed invoices retry against the right host.

How the flow works

  1. User clicks “Pay with crypto” → server action calls paymentManager.createCheckout({ priceId: '49', type: 'one_time', ... }).
  2. The provider POSTs to https://api.nowpayments.io/v1/invoice with price_amount: 49, price_currency: 'usd', an order_id packed as vbs|<userId>|<scene>|<type> so we can recover context from the IPN, and ipn_callback_url set to your /api/webhooks/nowpayments route.
  3. NOWPayments returns a hosted invoice URL like https://nowpayments.io/payment?iid=4514933743. We redirect the user there.
  4. The user picks a coin (BTC / ETH / USDC / …), sends the funds.
  5. NOWPayments emits multiple IPNs as the payment progresses: waiting → confirming → confirmed → sending → finished. The provider only treats finished as paid — the rest are normalized to unknown and ignored. Other terminal states (failed, expired, partially_paid, refunded) are also unknown from the handler’s perspective — those need manual reconciliation in the NOWPayments dashboard.
  6. On finished, the standard processNormalizedEvent flow runs: payment row inserted (idempotent on payment.invoiceId, which we set to the NOWPayments payment_id), credits granted, license issued if applicable.

Webhook signature verification

NOWPayments signs the IPN body with HMAC-SHA512 of the alphabetically-sorted JSON serialization of the body, using your IPN secret. The signature arrives in the x-nowpayments-sig header. The verification is implemented in verifyNowpaymentsWebhook and matches the algorithm shown in the official Node.js sample (JSON.stringify(params, Object.keys(params).sort())) and the official PHP WooCommerce plugin’s check_ipn_request_is_valid() (ksort + hash_hmac). Tests cover the round-trip plus tampering, malformed JSON, and wrong-length signatures.

Verify it works

  1. Use NOWPayments’ sandbox at sandbox.nowpayments.io for test transactions (separate API key + IPN secret).
  2. Expose localhost:3000 via cloudflared / ngrok, set the resulting URL as your IPN callback in the sandbox dashboard.
  3. pnpm dev, open /pricing, click checkout — NOWPayments hosted invoice page opens.
  4. Pay with the sandbox testnet faucet they provide.
  5. Watch the IPNs arrive — you’ll see several with payment_status going from waiting to finished.
  6. Check Postgres after finished:
    select id, provider, scene, status, amount, currency, invoice_id
    from payment
    where provider = 'nowpayments'
    order by created_at desc limit 1;
    

Common pitfalls

  • is_fee_paid_by_user: false — vibestrap’s provider always sets this so you eat the (small) network fee instead of surprising the buyer with a larger invoice. Toggle in src/payment/provider/nowpayments.ts if you’d rather pass it through.
  • Sandbox vs production keys are completely separate — including the IPN secret. Mixing them silently fails signature verification.
  • partially_paid requires manual reconciliation — if a buyer underpays (sent the wrong amount, or the price moved during the confirmation window), NOWPayments emits partially_paid and holds the funds. The provider ignores it; you’ll need to refund or top-up via the dashboard.
  • Refunds are out-of-band — there’s no Stripe-style “create refund” API; you initiate refunds in the NOWPayments dashboard. The handler doesn’t reverse credit grants automatically. If you need that, listen to the raw refunded IPN status and call refundCredits() yourself.
  • Order ID format is load-bearing — we encode vbs|<userId>|<scene>|<type> so the IPN handler can recover context (NOWPayments has no metadata field). If you change the prefix or delimiter, update parseOrderId in src/payment/provider/nowpayments.ts to match.
  • Price is fixed at invoice creation — if BTC moves 5% between invoice creation and the buyer paying, NOWPayments locks in the original quote. Buyers occasionally complain they “overpaid” or “underpaid” by a few cents; this is normal crypto-payment behavior, not a bug in vibestrap.
  • No customer portal — buyers can’t self-serve refunds, billing history, or payment method updates. Build your own /settings/billing page that reads from the payment table if you want a UI.

Switching from Stripe / Creem

Already running another provider? Three steps:
  1. Flip siteConfig.payment.provider to 'nowpayments'.
  2. Set the four NOWPAYMENTS_* env vars.
  3. Configure the IPN URL in the NOWPayments dashboard.
In-flight Stripe / Creem subscriptions keep working until they cancel naturally — vibestrap dispatches webhooks based on event.provider, and the shared handler is idempotent, so old provider events still hit the same payment table without conflict.

Official docs