Skip to content

BTCPay Server + MedusaJS v2 Payment Architecture

Overview

Research Relay LLC accepts Bitcoin payments (on-chain and Lightning) via a self-hosted BTCPay Server instance integrated with MedusaJS v2. This document covers deployment architecture, MedusaJS integration design, wallet segregation, and security.


1. Deployment Architecture

Hosting Decision: NixOS via nix-bitcoin

The existing NixOS server (shops-btc-01) is the deployment target. Rather than running BTCPay's default Docker-based deployment, we use nix-bitcoin — a collection of NixOS modules purpose-built for Bitcoin infrastructure.

Why nix-bitcoin over Docker: - Declarative, reproducible configuration matching our existing NixOS workflow - Security-hardened by default (systemd sandboxing, minimal attack surface) - Native integration between bitcoind, CLN, and BTCPay Server - No Docker overhead or container management complexity - Tor enabled by default for privacy

nix-bitcoin provides NixOS modules for: - bitcoind — full or pruned Bitcoin node - clightning — Core Lightning with plugin support (including CLBOSS) - btcpayserver — payment processor - electrs or fulcrum — Electrum server (optional) - rtl — Ride The Lightning web UI for channel management - mempool — block explorer (optional)

Minimal NixOS Configuration

# In the server's NixOS configuration (flake module)
{ config, pkgs, ... }:
{
  imports = [ <nix-bitcoin/modules/presets/secure-node.nix> ];

  # Bitcoin Core — pruned to save disk space
  services.bitcoind = {
    enable = true;
    prune = 100000;  # Keep ~100GB of blocks (supports Lightning)
    listen = true;
    # dataDir defaults to /var/lib/bitcoind
  };

  # Core Lightning with CLBOSS for automated channel management
  services.clightning = {
    enable = true;
    plugins.clboss.enable = true;
    # Announce via Tor onion service
    extraConfig = ''
      large-channels
      experimental-dual-fund
    '';
  };

  # BTCPay Server
  services.btcpayserver = {
    enable = true;
    lightningBackend = "clightning";
  };

  # Ride The Lightning for channel management UI
  services.rtl = {
    enable = true;
    nodes.clightning.enable = true;
  };

  # Tor onion services for BTCPay (optional, for .onion access)
  nix-bitcoin.onionServices.btcpayserver.enable = true;

  # Reverse proxy for public HTTPS access
  services.nginx = {
    enable = true;
    virtualHosts."btcpay.research-relay.com" = {
      forceSSL = true;
      enableACME = true;  # Let's Encrypt
      locations."/" = {
        proxyPass = "http://127.0.0.1:${toString config.services.btcpayserver.port}";
        proxyWebsockets = true;
      };
    };
  };
  security.acme.acceptTerms = true;
  security.acme.defaults.email = "admin@research-relay.com";
}

Hardware Requirements

Component Minimum Recommended
CPU 2 cores 4 cores
RAM 4 GB 8 GB
Storage (pruned ~100GB) 150 GB SSD 250 GB NVMe
Storage (full node) 800 GB SSD 1 TB+ NVMe
Network Port 9735 (Lightning), 443 (HTTPS) Static IP or DDNS

Recommendation: Run a pruned node at 100GB (prune=100000). CLN supports pruned nodes natively, which is why BTCPay Server officially recommends CLN. This keeps disk usage under 150GB total for the full stack (bitcoind + CLN + BTCPay + PostgreSQL).

Full Node vs Pruned Node Tradeoffs

Aspect Full Node Pruned Node
Disk usage 700GB+ (growing ~60GB/year) 25-100GB (configurable)
Payment processing Identical Identical
Security/validation Full history Same UTXO validation
Lightning support All implementations CLN only (LND requires full node)
Electrum server Required for Electrs/Fulcrum Not supported
Historical lookups Yes No

A pruned node validates the entire chain on initial sync, then discards old blocks. Payment processing and security are identical.

Domain and TLS

  • Domain: btcpay.research-relay.com (A record pointing to server IP)
  • TLS: Let's Encrypt via ACME (NixOS security.acme module)
  • Alternative: Cloudflare proxied DNS (provides DDoS protection but breaks websockets unless configured — prefer direct Let's Encrypt for BTCPay)
  • Tor: .onion address available via nix-bitcoin for admin access over Tor

Network Ports

Port Service Exposure
443 BTCPay Server (HTTPS via nginx) Public
80 ACME challenge (redirect to 443) Public
9735 Lightning Network (CLN) Public
8333 Bitcoin P2P (optional) Public (helps network)
23000 BTCPay Server (local) Localhost only
9836 RTL (local) Localhost only / VPN

2. MedusaJS v2 Integration

Plugin Assessment: medusa-plugin-btcpay (v0.0.6)

The existing npm package medusa-plugin-btcpay (by SGFGOV) is tagged medusa-v2 and claims Medusa v2 compatibility:

What it provides: - BTCPay Server integration via Greenfield API - Invoice creation for BTC payments - Webhook handling for payment status updates - Part of medusa-payment-plugins monorepo (also includes Razorpay) - TypeScript support - Cypress e2e tests

Assessment: - Use as starting point. Install and evaluate against the Medusa v2 AbstractPaymentProvider interface. The plugin is at v0.0.6, which signals early-stage development. - If it works with our Medusa v2 version, use it directly. - If it has gaps, fork it and extend — the codebase is a good reference for the BTCPay Greenfield API integration patterns. - Source: https://github.com/SGFGOV/medusa-payment-plugins

Custom Payment Provider Architecture (if needed)

If the plugin is insufficient, build a custom Payment Module Provider. Medusa v2 requires implementing AbstractPaymentProvider from @medusajs/framework/utils.

File Structure

src/modules/btcpay/
  index.ts          # ModuleProvider definition
  service.ts        # BtcPayPaymentProviderService extends AbstractPaymentProvider
  types.ts          # BTCPay-specific types
  client.ts         # Greenfield API client wrapper

Required Methods

import { AbstractPaymentProvider } from "@medusajs/framework/utils"
import { BigNumber } from "@medusajs/framework/utils"
import type {
  InitiatePaymentInput, InitiatePaymentOutput,
  AuthorizePaymentInput, AuthorizePaymentOutput,
  CapturePaymentInput, CapturePaymentOutput,
  RefundPaymentInput, RefundPaymentOutput,
  CancelPaymentInput, CancelPaymentOutput,
  UpdatePaymentInput, UpdatePaymentOutput,
  DeletePaymentInput, DeletePaymentOutput,
  RetrievePaymentInput, RetrievePaymentOutput,
  ProviderWebhookPayload, WebhookActionResult,
} from "@medusajs/framework/types"

type BtcPayOptions = {
  apiKey: string
  serverUrl: string    // https://btcpay.research-relay.com
  storeId: string
  webhookSecret: string
  speedPolicy: "HighSpeed" | "MediumSpeed" | "LowMediumSpeed" | "LowSpeed"
}

class BtcPayPaymentProviderService extends AbstractPaymentProvider<BtcPayOptions> {
  static identifier = "btcpay"

  // Create a BTCPay invoice when customer selects BTC payment
  async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput>

  // Check BTCPay invoice status — for BTC, authorization = payment confirmed
  async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput>

  // For BTC, capture is a no-op — payment is already settled on-chain/Lightning
  async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput>

  // BTC refunds must be handled manually (send on-chain tx to buyer's address)
  async refundPayment(input: RefundPaymentInput): Promise<RefundPaymentOutput>

  // Mark BTCPay invoice as invalid/cancelled
  async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput>

  // Fetch invoice status from BTCPay Greenfield API
  async retrievePayment(input: RetrievePaymentInput): Promise<RetrievePaymentOutput>

  // Update invoice metadata
  async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput>

  // Archive invoice in BTCPay
  async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput>

  // Handle BTCPay webhook events
  async getWebhookActionAndData(
    payload: ProviderWebhookPayload["payload"]
  ): Promise<WebhookActionResult>
}

Module Provider Definition

// src/modules/btcpay/index.ts
import BtcPayPaymentProviderService from "./service"
import { ModuleProvider, Modules } from "@medusajs/framework/utils"

export default ModuleProvider(Modules.PAYMENT, {
  services: [BtcPayPaymentProviderService],
})

Registration in medusa-config.ts

module.exports = defineConfig({
  modules: [
    {
      resolve: "@medusajs/medusa/payment",
      options: {
        providers: [
          {
            resolve: "./src/modules/btcpay",
            id: "btcpay",
            options: {
              apiKey: process.env.BTCPAY_API_KEY,
              serverUrl: process.env.BTCPAY_SERVER_URL,
              storeId: process.env.BTCPAY_STORE_ID,
              webhookSecret: process.env.BTCPAY_WEBHOOK_SECRET,
              speedPolicy: "MediumSpeed",  // 1 confirmation
            },
          },
        ],
      },
    },
  ],
})

BTCPay Greenfield API Endpoints Used

Operation Endpoint Method
Create invoice /api/v1/stores/{storeId}/invoices POST
Get invoice /api/v1/stores/{storeId}/invoices/{invoiceId} GET
Mark invoice invalid /api/v1/stores/{storeId}/invoices/{invoiceId}/status PUT
Create webhook /api/v1/stores/{storeId}/webhooks POST
Get payment methods /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods GET
Create refund /api/v1/stores/{storeId}/invoices/{invoiceId}/refund POST

API client approach: Use direct fetch calls against the Greenfield API rather than the stale btcpay-greenfield-node-client npm package (last updated 2021). The API is straightforward REST with API key auth via Authorization: token <apiKey> header.

Invoice State to Medusa Status Mapping

BTCPay Invoice State BTCPay Webhook Event Medusa Action Medusa Payment Status
New InvoiceCreated (initial) pending
Processing InvoiceProcessing authorized authorized
Settled InvoiceSettled captured captured
Expired InvoiceExpired failed canceled
Invalid InvoiceInvalid failed canceled
Settled (paidOver) InvoiceSettled captured captured (flag overpayment)

Key behavior differences from card payments: - BTC payments are push payments — the customer sends funds directly. There is no "authorize then capture" two-step. - authorizePayment should check if the BTCPay invoice is in Processing state (payment seen in mempool). - capturePayment is effectively a no-op — once settled, funds are already received. - Refunds require creating a new on-chain transaction (manual or via BTCPay's refund API).

Webhook Handling

Medusa v2 provides a built-in webhook endpoint at:

POST /hooks/payment/btcpay_btcpay
(Format: /hooks/payment/{identifier}_{id})

The getWebhookActionAndData method processes incoming BTCPay webhooks:

async getWebhookActionAndData(
  payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
  const { data, rawData, headers } = payload

  // Verify BTCPAY-SIG header using HMAC-SHA256 with webhook secret
  const sig = headers["btcpay-sig"]
  const expectedSig = crypto
    .createHmac("sha256", this.options_.webhookSecret)
    .update(rawData)
    .digest("hex")

  if (`sha256=${expectedSig}` !== sig) {
    return { action: "failed", data: { session_id: "", amount: new BigNumber(0) } }
  }

  const event = data as BtcPayWebhookEvent
  const sessionId = event.metadata?.medusa_session_id

  switch (event.type) {
    case "InvoiceSettled":
      return {
        action: "captured",
        data: { session_id: sessionId, amount: new BigNumber(event.amount) },
      }
    case "InvoiceProcessing":
      return {
        action: "authorized",
        data: { session_id: sessionId, amount: new BigNumber(event.amount) },
      }
    case "InvoiceExpired":
    case "InvoiceInvalid":
      return {
        action: "failed",
        data: { session_id: sessionId, amount: new BigNumber(event.amount) },
      }
    default:
      return {
        action: "not_supported",
        data: { session_id: sessionId, amount: new BigNumber(0) },
      }
  }
}

Checkout UX Flow

1. Customer adds items to cart
2. At checkout, selects "Pay with Bitcoin"
3. Medusa calls initiatePayment() → creates BTCPay invoice via Greenfield API
4. Frontend receives BTCPay checkout link (or invoice data for embedded display)
5. Customer is shown:
   - BTC on-chain address + QR code
   - Lightning invoice (BOLT11) + QR code
   - Amount in BTC
   - Countdown timer (default 15 minutes)
6. Customer pays via their wallet
7. BTCPay detects payment:
   - Lightning: instant settlement → InvoiceProcessing + InvoiceSettled webhooks fire immediately
   - On-chain: InvoiceProcessing fires when tx seen in mempool
                InvoiceSettled fires after configured confirmations (default: 1)
8. Webhook triggers getWebhookActionAndData() → Medusa creates order
9. Customer sees order confirmation

Frontend integration options: - Redirect to BTCPay checkout: Simplest. Use checkoutLink from invoice creation response. - Embedded modal: Use BTCPay's JavaScript modal library for in-page payment. - Custom UI: Fetch payment methods from Greenfield API and render your own BTC/Lightning payment UI.

Recommendation: Start with redirect to BTCPay checkout page. It handles QR codes, countdown timers, payment detection, and both on-chain/Lightning display. Customize later.


3. Wallet Segregation and Security

Wallet Architecture

                    +------------------+
                    |   Customer Pays  |
                    +--------+---------+
                             |
              +--------------+--------------+
              |                             |
    +---------v---------+      +------------v-----------+
    |  Lightning (CLN)  |      |  On-chain (BTCPay)     |
    |  Hot Wallet        |      |  Hot Wallet (xpub)     |
    |  ~100K-500K sats   |      |  Receiving only        |
    +--------+----------+      +-----------+------------+
             |                              |
             |  Loop out / close channels   |  Manual sweep
             |                              |
    +--------v------------------------------v------------+
    |           On-chain Staging Wallet                   |
    |           (BTCPay internal wallet)                  |
    |           Threshold: sweep at 0.01 BTC              |
    +----------------------------+-----------------------+
                                 |
                                 |  PSBT → Hardware wallet sign
                                 |
    +----------------------------v-----------------------+
    |           Cold Storage                              |
    |           Hardware wallet (Coldcard / Trezor)       |
    |           xpub-only in BTCPay for watch             |
    +----------------------------------------------------+

Hot Wallet (Lightning + Small On-chain)

  • Lightning wallet: Managed by CLN. Inherently hot (keys on server). Keep channel capacity proportional to expected daily volume.
  • On-chain receiving wallet: BTCPay generates addresses from an xpub. Two options:
  • xpub-only (cold): Funds land in addresses controlled by a hardware wallet. Safest. Requires hardware wallet to spend.
  • Hot wallet enabled: BTCPay stores keys on server. Enables Payjoin and automated payouts. Higher risk.

Recommendation for Research Relay: Use xpub-only for on-chain receiving. Connect a Coldcard or Trezor via BTCPay's hardware wallet interface for spending. Keep Lightning hot wallet funded at a level matching expected weekly Lightning volume (start with 500K sats / ~$200).

Cold Storage Strategy

  • Store the hardware wallet seed phrase on stamped metal (e.g., Seedplate) in a secure location.
  • Do NOT store seed phrases digitally.
  • Use a single-sig setup — multi-sig is unnecessary complexity for a solo operator with modest volume.
  • Consider a passphrase (25th word) for additional security.

Sweep Policy

  • Lightning to on-chain: When Lightning channel local balance exceeds threshold, use submarine swaps (Boltz, Loop) to move funds on-chain. CLBOSS can automate this.
  • On-chain to cold storage: When BTCPay on-chain wallet balance exceeds 0.01 BTC (~$400 at time of writing), manually sweep to cold storage using PSBT signed on hardware wallet.
  • Frequency: Weekly review of balances; sweep whenever threshold is hit.

Seed Phrase Management

Secret Location Backup
CLN hsm_secret Server (auto-generated by nix-bitcoin) Encrypted backup to offsite
On-chain xpub BTCPay Server config Hardware wallet has master seed
Hardware wallet seed Metal plate in safe Second copy in separate location
BTCPay admin password Password manager N/A
Greenfield API key Medusa .env Password manager
Webhook secret Medusa .env + BTCPay config Password manager

Backup Strategy

What How Frequency
CLN hsm_secret Copy from /var/lib/clightning/bitcoin/hsm_secret, encrypt, store offsite One-time (does not change)
CLN database tar of CLN data directory (careful: old state is toxic) Daily automated + before updates
BTCPay PostgreSQL pg_dump Daily automated
BTCPay Server config NixOS configuration is in git (declarative) Every change (git commit)
Bitcoin Core chainstate Not backed up (re-syncable) Never needed
Hardware wallet seed Metal plate One-time

Encrypted backup script (add to NixOS config):

systemd.services.btcpay-backup = {
  description = "BTCPay + CLN encrypted backup";
  serviceConfig.Type = "oneshot";
  script = ''
    BACKUP_DIR="/var/backups/btcpay"
    mkdir -p $BACKUP_DIR

    # PostgreSQL dump
    ${pkgs.postgresql}/bin/pg_dump btcpayserver > $BACKUP_DIR/btcpay-db.sql

    # CLN database (only if CLN is stopped or using backup plugin)
    cp /var/lib/clightning/bitcoin/lightningd.sqlite3 $BACKUP_DIR/

    # Encrypt and upload
    ${pkgs.gnupg}/bin/gpg --symmetric --cipher-algo AES256 \
      --batch --passphrase-file /run/secrets/backup-passphrase \
      --output $BACKUP_DIR/backup-$(date +%Y%m%d).tar.gz.gpg \
      <(tar czf - -C $BACKUP_DIR btcpay-db.sql lightningd.sqlite3)

    # Upload to offsite (S3, rsync, etc.)
    # ${pkgs.rclone}/bin/rclone copy $BACKUP_DIR/backup-*.gpg remote:backups/
  '';
};

systemd.timers.btcpay-backup = {
  wantedBy = [ "timers.target" ];
  timerConfig.OnCalendar = "daily";
};


4. Accounting Integration

BTC Payment to Accounting Flow

BTCPay Invoice Settled
  |
  +---> BTCPay records: BTC amount, tx hash, timestamp
  |
  +---> Medusa records: order ID, USD amount, payment session data
  |
  +---> Export flow:
        1. BTCPay CSV export (on-chain wallets report, payments report)
        2. Map each payment to Medusa order (via orderId in invoice metadata)
        3. Record FMV (fair market value) in USD at time of confirmation
        4. Import to Koinly for tax tracking
        5. Reconcile with Medusa order records monthly

BTCPay Reporting Capabilities

BTCPay Server has built-in reports accessible from the dashboard:

  • On-Chain Wallets: Each line shows on-chain transactions affecting the wallet (txid, amount, date, confirmations).
  • Payments: Each line represents an accounted payment to an invoice (invoice ID, amount, currency, payment method, date).
  • Products Sold: Each line represents a quantity of items sold (useful for POS).

All reports are exportable as CSV.

Tax Tracking with Koinly

  1. Export BTCPay CSV monthly (Payments report)
  2. Import to Koinly as custom CSV:
  3. Map columns: date, amount (BTC), type (income/payment received)
  4. Koinly calculates FMV in USD at time of receipt
  5. Koinly tracks cost basis for any future BTC sales
  6. Koinly generates:
  7. Income report (BTC received as business income at FMV)
  8. Capital gains report (if BTC is later sold/exchanged)
  9. Form 8949 and Schedule D for US tax filing
  10. TurboTax-compatible export

Automated Reconciliation (Future)

For automated reconciliation between BTCPay and Medusa:

// Pseudocode for monthly reconciliation script
async function reconcile(month: string) {
  // 1. Fetch all settled invoices from BTCPay for the month
  const invoices = await btcpayClient.getInvoices(storeId, {
    status: "Settled",
    startDate: monthStart,
    endDate: monthEnd,
  })

  // 2. Fetch all BTC orders from Medusa for the month
  const orders = await medusaClient.getOrders({
    payment_provider: "btcpay",
    created_at: { gte: monthStart, lte: monthEnd },
  })

  // 3. Match by orderId stored in invoice metadata
  const matched = invoices.map(inv => ({
    btcpay_invoice_id: inv.id,
    medusa_order_id: inv.metadata.orderId,
    btc_amount: inv.amount,
    fmv_usd: inv.currency === "USD" ? inv.amount : lookupFMV(inv.settlementDate, inv.btcAmount),
    status: orders.find(o => o.id === inv.metadata.orderId) ? "matched" : "UNMATCHED",
  }))

  // 4. Flag discrepancies
  const unmatched = matched.filter(m => m.status === "UNMATCHED")
  if (unmatched.length > 0) {
    alert(`${unmatched.length} unmatched payments found`)
  }

  return matched
}

Key Accounting Considerations

  • Income recognition: BTC received as payment is income at FMV on date of receipt (IRS Revenue Ruling 2014-21).
  • Invoice currency: Create BTCPay invoices in USD (BTCPay handles BTC conversion at current rate). This simplifies accounting — the USD amount is the revenue.
  • Holding vs. converting: If holding BTC, track cost basis. If converting to USD immediately (not planned initially), record as income + immediate sale.
  • Mercury bank reconciliation: BTC payments do not flow through Mercury. Reconcile separately.